| name | typescript-best-practices |
| description | TypeScript best practices distilled from Matt Pocock's TotalTypeScript.com. Naming conventions, generics usage, type inference, config settings, and practical patterns for production TypeScript code. |
| risk | unknown |
| source | community |
| date_added | 2026-04-28 |
TypeScript Best Practices
Practical TypeScript wisdom from TotalTypeScript.com by Matt Pocock.
Covers naming, generics, inference, config, and everyday patterns.
When to Use
Use this skill when writing TypeScript code, making naming decisions, choosing between type system features, configuring tsconfig, or deciding when to use generics vs overloads vs unions.
1. Type Naming Conventions
Rules
- Never pluralize — Types represent a single value, even unions. Use
Route, not Routes.
- Different casing from variables — Types in PascalCase, variables in camelCase. Prevents syntax highlighting confusion.
- Prefix generics with T — Single generic:
T. Multiple: TData, TError. Never T, U, V, W chains.
- No Hungarian notation — Avoid
IUser, TUser, COrganization. The prefix adds noise; hover tells you the kind.
type Routes = "user" | "admin";
type TUser = { name: string };
interface IOrg { name: string }
type Route = "user" | "admin";
type User = { name: string };
interface Organization { name: string }
2. When to Use Generics
Decision Framework
- Zero dynamic elements? — No generics. Use concrete types.
- Dynamic but all shapes known upfront? — Union type suffices.
- Return type must vary based on specific input? — Use generics.
export const getDisplayName = (
item: Animal | Human
): { displayName: string } => {
if ("name" in item) return { displayName: item.name };
return { displayName: `${item.firstName} ${item.lastName}` };
};
export const getDisplayName = <TItem extends Animal | Human>(
item: TItem
): TItem extends Human ? { humanName: string } : { animalName: string } => { ... };
Curried Generics
Generics lock per function call. Use currying to carry types through:
const makeKeyRemover =
<Key extends string>(keys: Key[]) =>
<Obj>(obj: Obj): Omit<Obj, Key> => {
return {} as any;
};
const removeAB = makeKeyRemover(["a", "b"]);
const result = removeAB({ a: 1, b: 2, c: 3 });
3. Return Type Annotations
Default: don't annotate. Let TypeScript infer. Annotate only when:
- Multiple branches — Acts as documentation, constrains the union.
- Library code — Return type is public API; annotate for stability.
- Performance — Complex generics can slow inference; annotate to help.
const makeId = (prefix: string, num: number) => `${prefix}-${num}`;
const handleState = (state: State): { status: "loading" | "error" | "success" } => { ... };
4. Inference Mental Model
let vs const vs as const
let → broad type (number)
const → literal type (31)
- Arrays/objects:
const alone doesn't freeze members; use as const for deep literal inference
let age = 31;
const age = 31;
const arr = ["a"];
const arr = ["a"] as const;
satisfies Pattern
Enforce a shape without losing literal types:
const satisfies =
<T>() =>
<U extends T>(u: U) =>
u;
const actions = satisfies<{ action: string; role: string }[]>()([
{ action: "create", role: "admin" },
{ action: "read", role: "user" },
]);
5. Assignability and extends
A extends B asks: "Is A a narrower version of B?"
type Result = string extends "matt" | "fred" ? true : false;
type Result = "matt" | "fred" extends string ? true : false;
Think of it like OOP: Labrador extends Dog — Labrador is narrower. Animal is wider and won't pass takeForWalk(dog: Dog).
6. tsconfig Essentials
noUncheckedIndexedAccess
Enable it. Dynamic key access returns T | undefined, preventing runtime errors:
{ "compilerOptions": { "noUncheckedIndexedAccess": true } }
const obj: Record<string, string[]> = {};
obj.foo.push("bar");
if (!obj.foo) { obj.foo = []; }
obj.foo.push("bar");
7. Indexed Access Types
Access object properties, tuple members, and nested structures at the type level:
type PrimaryColor = ColorVariants["primary"];
type AOrB = Letters[0 | 1];
type AllLetters = Letters[number];
type Role = UserRoleConfig[keyof UserRoleConfig][number];
8. Distributive Conditional Types
Conditional types automatically distribute over unions:
type RemoveC<T> = T extends "c" ? never : T;
type Result = RemoveC<"a" | "b" | "c">;
type SwapC<T> = T extends "c" ? "d" : T;
type Result2 = SwapC<"a" | "b" | "c">;
9. Dynamic Function Arguments
Use conditional types + named tuples to make function args vary by input:
const sendEvent = <Type extends Event["type"]>(
...args: Extract<Event, { type: Type }> extends { payload: infer P }
? [type: Type, payload: P]
: [type: Type]
) => {};
sendEvent("SIGN_OUT");
sendEvent("LOG_IN", { userId: "1" });
sendEvent("LOG_IN");
10. Key Manipulation with infer
Use infer inside template literal types to transform object keys:
type RemoveMaps<T> = T extends `maps:${infer Suffix}` ? Suffix : T;
type CleanData = {
[K in keyof ApiData as RemoveMaps<K>]: ApiData[K];
};
11. declare global for Cross-Module Types
Extend a global interface from any module file:
declare global {
interface GlobalReducerEvent {
ADD_TODO: { text: string };
}
}
Multiple modules can extend the same interface; a mapping type converts it to a discriminated union.
12. Derive Types from Modules
Use typeof import() to turn a module's exports into a type:
type ActionModule = typeof import("./constants");
type Action = ActionModule[keyof ActionModule];
13. Autocomplete with Arbitrary Values
LooseAutocomplete<T> preserves autocomplete while accepting any string:
type LooseAutocomplete<T extends string> = T | Omit<string, T>;
interface IconProps {
size: LooseAutocomplete<"sm" | "xs">;
}
14. DeepPartial for Nested Types
Built-in Partial<T> is one level deep. Use recursive DeepPartial for mocks/fixtures:
type DeepPartial<T> = T extends Function
? T
: T extends Array<infer U>
? Array<DeepPartial<U>>
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T | undefined;
Check order matters: functions and arrays before objects (they're all object in JS).
15. Type-Level Error Messages
Return a string literal from a conditional type to create custom IDE error messages:
type CheckForBadArgs<Arg> = Arg extends any[]
? "You cannot compare two arrays using deepEqualCompare"
: Arg;
const deepEqualCompare = <Arg>(
a: CheckForBadArgs<Arg>,
b: CheckForBadArgs<Arg>
): boolean => { ... };
16. Generics in React Components
Arrow functions can't use <T> in TSX (conflicts with JSX). Use function declarations:
interface TableProps<TItem> {
items: TItem[];
renderItem: (item: TItem) => React.ReactNode;
}
export function Table<TItem>(props: TableProps<TItem>) {
return null;
}
17. Generics vs Function Overloads
- Overloads: fixed, known set of types. Each overload is explicit.
- Generics: open-ended, preserves the exact type passed in.
function fn(input: string): string;
function fn(input: number): number;
function fn(input: unknown): unknown { return input; }
function fn<T>(input: T): T { return input; }
18. Chained extends for Deep Access
Narrow generics with chained extends constraints:
const getDeepValue = <
Obj,
FirstKey extends keyof Obj,
SecondKey extends keyof Obj[FirstKey]
>(
obj: Obj,
firstKey: FirstKey,
secondKey: SecondKey,
): Obj[FirstKey][SecondKey] => { ... };
getDeepValue(obj, "bar", "d");
19. Default Generic Slots as Local Variables
Extract repeated sub-expressions into default generic parameters:
type ValuesOfKeysStartingWithA<Obj> = {
[K in Extract<keyof Obj, `a${string}`>]: Obj[K]
}[Extract<keyof Obj, `a${string}`>]
type ValuesOfKeysStartingWithA<
Obj,
_Keys extends keyof Obj = Extract<keyof Obj, `a${string}`>
> = { [K in _Keys]: Obj[K] }[_Keys]
20. Force Explicit Property Passing
Use | undefined on optional properties to force call sites to be explicit:
interface UserInfo {
role?: "admin" | undefined;
}
createUser({});
createUser({ role: undefined });
21. Derive Union from Object
Map over an object's keys and index to collapse into a union:
type SingleFruitCount = {
[K in keyof FruitCounts]: { [K2 in K]: number }
}[keyof FruitCounts];
22. Transform Unions with in Operator
Use mapped types to iterate over union members and derive new unions:
type EntityWithId = {
[E in Entity["type"]]: { type: E } & Record<`${E}Id`, string>
}[Entity["type"]];
23. Assertion Functions in Classes
Narrow this type with assertion methods:
class SDK {
userId?: string;
assertLoggedIn(): asserts this is this & { userId: string } {
if (!this.userId) throw new Error("Not logged in");
}
getUser() {
this.assertLoggedIn();
}
}
24. useState Mental Model (React)
| Declaration | Type |
|---|
useState() | undefined — can't set anything |
useState<string>() | string | undefined |
useState([]) | never[] — can't add items |
useState<Type[]>([]) | Correct — pass both type arg and initial value |
25. Function Overloads for Compose
Chain overloads with generics to type function composition:
function compose<I, A>(fn: (i: I) => A): (i: I) => A;
function compose<I, A, B>(fn: (i: I) => A, fn2: (a: A) => B): (i: I) => B;
function compose(...args: any[]): any { return {} as any; }
26. Typed objectKeys Helper
Object.keys() returns string[]. Create a typed version:
const objectKeys = <Obj>(obj: Obj): (keyof Obj)[] => {
return Object.keys(obj) as (keyof Obj)[];
};
27. Extract Props from Components
Use infer with conditional types to extract props from React components:
type PropsFrom<C> = C extends React.FC<infer P>
? P
: C extends React.Component<infer P>
? P
: never;
Source
These practices are distilled from the 34 video tips at TotalTypeScript.com/tips by Matt Pocock.