Advanced TypeScript type system patterns — branded types, discriminated unions, conditional types, mapped types, template literals, type predicates, and variance
npx skills add m10rten/typescript-bits --skill type-system-patternsskill.md · 313 lines~3kDeep-end TypeScript. These patterns go beyond utility types and tsconfig — they cover how to model domain invariants, derive types, and make illegal states unrepresentable.
TypeScript is structurally typed: two types with the same shape are interchangeable. Branding breaks that when semantic identity matters.
declare const __brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [__brand]: B };
/** Opaque user ID — not assignable from plain string */
export type UserId = Brand<string, "UserId">;
/** Opaque post ID — not assignable from plain string */
export type PostId = Brand<string, "PostId">;
/** Constructor validates and brands the raw value */
export function makeUserId(raw: string): UserId {
if (!raw.trim()) throw new Error("UserId cannot be empty");
return raw as UserId;
}
function getUser(id: UserId): void {
/* ... */
}
const raw = "abc-123";
getUser(raw); // Error: Argument of type 'string' is not assignable
getUser(makeUserId(raw)); // OK`unique symbol` over string literalA `unique symbol` brand cannot be forged from another file without importing the symbol — stronger than `{ __brand: "UserId" }` which any object literal can satisfy. However, for most cases a string literal brand is simpler and sufficient; use `unique symbol` when collision risk is real.
export type Milliseconds = Brand<number, "Milliseconds">;
export type Seconds = Brand<number, "Seconds">;
export const ms = (n: number): Milliseconds => n as Milliseconds;
export const sec = (n: number): Seconds => n as Seconds;
function delay(duration: Milliseconds): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, duration));
}
delay(sec(5)); // Error — prevents silent unit mismatch
delay(ms(5000)); // OKModel states as a closed set. Each variant carries exactly the data it needs; others are absent.
`boolean` (boolean discriminants produce confusing inference)type FetchState<T> =
| { status: "idle" }
| { status: "loading"; startedAt: number }
| { status: "success"; data: T; fetchedAt: number }
| { status: "error"; error: Error; retries: number };`never`function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unhandled variant: ${JSON.stringify(value)}`);
}function render<T>(state: FetchState<T>): string {
switch (state.status) {
case "idle":
return "Idle";
case "loading":
return `Loading since ${state.startedAt}`;
case "success":
return `Data: ${JSON.stringify(state.data)}`;
case "error":
return `Error (${state.retries} retries): ${state.error.message}`;
default:
return assertNever(state);
}
}Adding a new variant (`"cancelled"`) causes a compile error at the `assertNever` call — not a silent runtime miss.
TypeScript narrows on any literal field, not just a dedicated `kind`. But mixing business data with the discriminant gets messy fast — keep the discriminant dedicated.
Compute types based on type-level conditions. Three primitives: `extends`, `infer`, distributive behavior.
`infer` — Extract from Structuretype Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type FirstArg<T> = T extends (first: infer A, ...rest: any[]) => any ? A : never;
// Extract the resolved value from a deeply nested promise
type DeepAwaited = Awaited<Promise<Promise<string>>>; // stringWhen the checked type is a naked type parameter, the conditional distributes over unions:
type IsString<T> = T extends string ? true : false;
type R = IsString<string | number>; // true | false — distributed
// Prevent distribution by wrapping in a tuple
type IsStringExact<T> = [T] extends [string] ? true : false;
type R2 = IsStringExact<string | number>; // false — no distributionUse tuple-wrap when you want to test a union as a whole, not element by element.
type Extract<T, U> = T extends U ? T : never;
type Exclude<T, U> = T extends U ? never : T;
type Events = "click" | "focus" | "blur" | "change";
type FocusEvents = Extract<Events, "focus" | "blur">; // "focus" | "blur"Iterate over keys to derive new object types. Three clauses: `in`, `as` (key remapping), `?`/`-?`/`readonly`/`-readonly`.
// Readonly deep clone
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
// Getters — key remapping with `as`
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
// Remove optionality
type Required<T> = { [K in keyof T]-?: T[K] };
// Filter keys by value type
type PickByValue<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K];
};
type User = { id: string; age: number; active: boolean };
type StringFields = PickByValue<User, string>; // { id: string }`as never` removes the key entirely`as` clause receives the key type `K`, not the value`get${...}`, `on${...}`)Construct and decompose string types at the type level. Most useful for event names, CSS shorthand, and API paths.
type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type Route = `/${string}`;
type Endpoint = `${HTTPMethod} ${Route}`;
// Typed event emitter
type EventMap = { click: MouseEvent; keydown: KeyboardEvent; resize: UIEvent };
type OnEvent = `on${Capitalize<keyof EventMap & string>}`;
// "onClick" | "onKeydown" | "onResize"
// Extract route params
type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<`/${Rest}`>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"TypeScript provides four built-in string manipulation types: `Uppercase, ``Lowercase, ``Capitalize, ``Uncapitalize. All are resolved at compile time with no runtime cost.`
Narrow types based on runtime checks while keeping the call site readable.
`x is T`)function isError(value: unknown): value is Error {
return value instanceof Error;
}
function isNonNull<T>(value: T | null | undefined): value is T {
return value != null;
}
// Usage
const items: (string | null)[] = ["a", null, "b", null, "c"];
const strings: string[] = items.filter(isNonNull);`asserts x is T`)Unlike predicates, assertion functions throw instead of returning `false`. TypeScript narrows the type for the rest of the enclosing scope after the call.
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new TypeError(`Expected string, got ${typeof value}`);
}
}
function assertDefined<T>(value: T, label = "value"): asserts value is NonNullable<T> {
if (value == null) throw new Error(`${label} must be defined`);
}
// Usage
function process(input: unknown): string {
assertIsString(input);
return input.toUpperCase(); // narrowed to string here
}| Situation | Pattern |
|---|---|
`Array.filter`, conditional branches | Type predicate (`x is T`) |
| Validate at boundary, throw on failure | Assertion function (`asserts x is T`) |
Simple `instanceof`/`typeof` check inline | Inline check — no helper needed |
Variance describes how a generic type `Container relates to `Container` when `T extends U`.
A type is covariant in `T` when `T` only appears as a return value (output). `Container — the more specific type is assignable to the wider one.
type Producer<T> = { produce: () => T };
// Producer<Dog> is assignable to Producer<Animal> ✓A type is contravariant in `T` when `T` only appears as a parameter (input). `Container is assignable to `Container — you need to flip.
type Consumer<T> = { consume: (value: T) => void };
// Consumer<Animal> is assignable to Consumer<Dog> ✓
// (a function that handles any Animal can handle a Dog)When `T` appears in both input and output, neither direction is safe. The type is invariant — only `Container is assignable to `Container.
type ReadWrite<T> = { get: () => T; set: (v: T) => void }; // invariant`readonly` arrays (`ReadonlyArray` ) are covariant — safe to assign `Dog[]` to `Animal[]``strictFunctionTypes: true`)`Animal` can stand in for one expecting `Dog`When designing generic APIs: if a type parameter only flows out, make the container `readonly`; if it only flows in, the callback pattern naturally handles contravariance.
| Mistake | Fix |
|---|---|
Branding with `{ __brand: "X" }` as an intersection without a constructor | Always pair the brand with a constructor that validates and casts — raw `as BrandedType` at call sites defeats the purpose |
Forgetting `assertNever` in exhaustive switches | Add `default: return assertNever(x)` — compiler catches new union variants silently otherwise |
| Distributing over a union unintentionally | Wrap the checked type in `[T] extends [U]` to suppress distribution |
Using `as` key remapping without `string &` guard | `Capitalize requires `K extends string` — use `string & K` or `K & string` to narrow |
| Writing type predicates that lie | A predicate returning `x is Foo` that doesn't actually verify the full shape is worse than no predicate — over-narrow or use `unknown` + validate completely |
| Modeling mutable state as covariant | A `Container assigned to `Container allows pushing a `Cat` — use `ReadonlyArray` or invariant generics for mutable containers |
Using `infer` in a non-conditional position | `infer` only works inside the `extends` clause of a conditional type — it cannot appear in mapped types or plain generics |
| Ignoring variance when wrapping callbacks | Passing `(animal: Animal) => void` where `(dog: Dog) => void` is expected is safe; the reverse is not — understand contravariance before inverting callbacks |