TypeScript Advanced: Patterns, Pitfalls & type-fest
This skill defines the rules, conventions, and architectural decisions for writing advanced TypeScript. It is intentionally opinionated to prevent common type-level bugs and enforce patterns that produce safe, maintainable code.
For detailed API documentation of TypeScript features, use other appropriate tools (documentation lookup, web search, etc.) — this skill focuses on when, why, and how to use advanced type features correctly.
Type Safety Philosophy
any vs unknown vs never — the only rule you need
| Type | Assignable from | Assignable to | Operations | Use for |
|---|---|---|---|---|
any | anything | anything | all (UNSAFE) | Never in new code |
unknown | anything | only unknown / any | none unnarowed | External inputs, JSON, user data |
never | nothing | anything | none | Exhaustive checks, unreachable code |
Rule: never use any in new code. Use unknown for external boundaries and narrow before operating. Use never for exhaustiveness and impossible states.
Prefer unions over enums
// Avoid — numeric enums are structurally assignable to number (footgun)
enum Direction {
Up,
Down,
Left,
Right,
}
function go(d: Direction) {}
go(42); // no error — TypeScript allows any number!
// Prefer — exhaustive, tree-shakeable, no runtime artifact
type Direction = "up" | "down" | "left" | "right";String enums are safer than numeric but still carry runtime overhead and import friction. String literal unions are the default choice unless you need reverse mapping.
interface vs type — decision table
| Scenario | Use | Why |
|---|---|---|
| Object shapes, class contracts | interface | Declaration merging, better error messages |
| Unions, intersections, mapped/conditional | type | Only type supports these |
| Third-party augmentation needed | interface | Only interfaces support declaration merging |
| Public API types (libraries) | interface | Consumers can augment; better display in tooltips |
| Internal computed types | type | More expressive, no accidental merging |
Discriminated Unions & Exhaustive Checks
The never exhaustiveness pattern
Every switch / if-else chain on a discriminated union must handle all variants. Use the never assignment to get a compile-time error when a new variant is added:
type Result<T> =
| { status: "ok"; data: T }
| { status: "error"; error: Error }
| { status: "loading" };
function handle<T>(result: Result<T>): string {
switch (result.status) {
case "ok":
return JSON.stringify(result.data);
case "error":
return result.error.message;
case "loading":
return "Loading...";
default:
const _exhaustive: never = result;
return _exhaustive; // compile error if a variant is unhandled
}
}Rules for discriminated unions
- Discriminant must be a literal type —
string,number,booleanliterals. Wide types likestringdo not narrow. - Keep the discriminant property name consistent across all members (
kind,type,status). - Avoid optional discriminants —
status?: "ok" | "error"breaks narrowing.
Branded Types — Nominal Safety in a Structural System
TypeScript is structural: UserId (a string) and OrderId (a string) are interchangeable by default. Branded types break this at the type level with zero runtime overhead.
Recommended pattern: unique symbol brand
declare const __brand: unique symbol;
type Brand<T, B> = T & { readonly [__brand]: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
// Constructor = the single trust boundary, validate here
const toUserId = (id: string): UserId => id as UserId;
const toOrderId = (id: string): OrderId => id as OrderId;
function getUser(id: UserId) {
/* ... */
}
getUser(toUserId("abc")); // ok
getUser(toOrderId("abc")); // ERROR — OrderId not assignable to UserId
getUser("abc"); // ERROR — string not assignable to UserIdWhen to use branded types
- IDs —
UserId,OrderId,ProductIdprevent cross-assignment - Units —
Meters,Feet,USD,EURprevent arithmetic mistakes - Validated strings —
Email,URL,Slugencode that validation has happened - Opaque tokens —
JWTToken,APIKeyprevent accidental logging/display
type-fest alternative
Use Tagged<T, Tag> from type-fest for multi-tag composition and metadata:
import type { Tagged, GetTagMetadata } from "type-fest";
type UserId = Tagged<string, "UserId">;
type AdminId = Tagged<UserId, "Admin">; // composable — both tags preservedModern Inference Tools
satisfies — validate without widening (TS 4.9+)
type Theme = Record<"primary" | "secondary", string | string[]>;
// Type annotation: loses specific types
const t1: Theme = { primary: "#000", secondary: ["#111", "#222"] };
t1.secondary.map((s) => s); // ERROR — string | string[] has no .map
// satisfies: validates structure, keeps specific inference
const t2 = { primary: "#000", secondary: ["#111", "#222"] } satisfies Theme;
t2.secondary.map((s) => s); // ok — inferred as string[]Use satisfies when: you want config validation (catch typos in keys) but also need autocomplete on specific values.
const type parameters — generic literal inference (TS 5.0+)
// Without const: T = string[]
function routes<T extends string[]>(r: T): T {
return r;
}
// With const: T = readonly ["users", "posts"]
function routes<const T extends string[]>(r: T): T {
return r;
}
const r = routes(["users", "posts"]); // readonly ["users", "posts"]Use const type parameters when: building registries, config factories, or any generic where preserving literal types at the call site matters.
NoInfer<T> — control inference sources (TS 5.4+)
Prevents a parameter from contributing to type inference — it reads T but doesn't influence what T becomes:
function createFSM<const TState extends string>(config: {
states: TState[];
initial: NoInfer<TState>; // must be from states, can't introduce new values
}) {
/* ... */
}
createFSM({ states: ["idle", "running"], initial: "idle" }); // ok
createFSM({ states: ["idle", "running"], initial: "stopped" }); // ERRORUse NoInfer when: a function has multiple parameters sharing a type param, and one should be constrained to what the others infer — not contribute new candidates.
type-fest: Don't Reinvent the Wheel
type-fest provides 200+ utility types with zero runtime cost (types-only). Always check type-fest before writing a custom utility.
import type { Simplify, Merge, SetRequired, LiteralUnion } from "type-fest";Decision table: built-in vs type-fest
| Need | Built-in | type-fest |
|---|---|---|
| Make keys optional/required | Partial, Required | SetOptional, SetRequired (per-key) |
| Deep partial/readonly | — | PartialDeep, ReadonlyDeep |
| Merge two types (override, not intersect) | — | Merge, MergeDeep |
| Flatten intersection for readability | — | Simplify |
String union with autocomplete + string | — | LiteralUnion |
| At-least/exactly-one constraint | — | RequireAtLeastOne, RequireExactlyOne |
| Nominal/branded types | — | Tagged, UnwrapTagged |
| JSON round-trip type | — | Jsonify |
| Strict omit (key must exist) | Omit (loose) | Except (strict) |
| Deep dot-path access | — | Paths, Get |
| Exact object (reject excess props) | — | Exact |
| Pick/omit by value type | — | ConditionalPick, ConditionalExcept |
| Package.json / tsconfig types | — | PackageJson, TsConfigJson |
| Case conversion for keys | — | CamelCasedProperties, etc. |
Most commonly needed utilities
Simplify<T>— flattensA & B & Cinto readable{...all keys}. Use on any intersection that produces unreadable hover tooltips.Merge<A, B>—A & Bproducesneverwhen keys conflict;Mergecleanly overrides. Use instead of&when types share key names.LiteralUnion<Literal, Base>—'a' | 'b' | stringkills autocomplete;LiteralUnionpreserves it. Essential for extensible string APIs.SetRequired<T, K>/SetOptional<T, K>— toggle specific keys without maintaining duplicate interfaces.Jsonify<T>— modelsJSON.parse(JSON.stringify(x)). CatchesDate→string,undefined→ dropped, interface open-index issues.
Common Pitfalls
anyleaks silently — oneanypropagates through assignments, generics, and return types. A singleanyin a utility type makes all downstream types unsound. Useunknown+ narrowing instead.- Excess property checks only apply to literals — assigning through a variable bypasses excess property checks entirely. Don't rely on them for runtime safety.
interface Point {x: number; y: number;} const obj = {x: 1, y: 2, z: 3}; const p: Point = obj; // no error — z slips through - Distributive conditional type on
never—T extends X? A: BwhereTisneverreturnsnever(notB). Wrap in tuples:[T] extends [X]. Omitdoesn't check key existence —Omit<T, "typo">silently succeeds. UseExceptfrom type-fest for strict key checking.- Type widening with
let—let x = "hello"isstring, not"hello". Useconst,as const, orsatisfiesto preserve literals. &intersection with conflicting keys —{a: string} & {a: number}makesa: never. UseMergefrom type-fest instead.- Enum numeric assignability —
enum Foo {A, B}allowsconst x: Foo = 999. Use string literal unions instead. interfaceaccidental merging — twointerface User {}declarations in the same scope silently merge. Usetypefor internal types that should not be extended.const enumunderisolatedModules— esbuild, SWC, Babel all useisolatedModules.const enumin.d.tsor library code breaks these builds.- Forgetting
readonlyon array parameters —function f(arr: string[])allows mutation. Usereadonly string[]for params you don't intend to mutate. - Structural subtyping function params — method syntax
push(x: T)is bivariant (unsound). Use function property syntaxpush: (x: T) => voidunderstrictFunctionTypesfor correct variance. - Reinventing type-fest utilities — check type-fest before writing
DeepPartial,DeepReadonly,Merge, branded types, or key manipulation types. The library handles edge cases (circular refs, readonly arrays, maps/sets) that hand-rolled versions miss.
Reference Files
Read the relevant reference file when working with a specific pattern:
| File | When to read |
|---|---|
references/conditional-types.md | infer, distributive conditionals, constraining with extends |
references/mapped-types.md | Key remapping, filtering, template literal key manipulation |
references/template-literals.md | String manipulation at type level, pattern matching, parsing |
references/module-augmentation.md | Declaration merging, extending third-party types, global scope |
references/type-fest.md | Full type-fest utility catalog by category with usage examples |