typescript-advanced
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 UserId
When 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 preserved
Modern 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" }); // ERROR
Use 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 |
More from trancong12102/agentskills
deps-dev
Look up the latest stable version of any open-source package across npm, PyPI, Go, Cargo, Maven, and NuGet. Use when the user asks 'what's the latest version of X', 'what version should I use', 'is X deprecated', 'how outdated is my package.json/requirements.txt/Cargo.toml', or needs version numbers for adding or updating dependencies. Also covers pinning versions, checking if packages are maintained, or comparing installed vs latest versions. Do NOT use for private/internal packages or for looking up documentation (use context7).
151oracle
Deep analysis and expert reasoning. Use when the user asks for 'oracle', 'second opinion', architecture analysis, elusive bug debugging, impact assessment, security reasoning, refactoring strategy, or trade-off evaluation — problems that benefit from deep, independent reasoning. Do not use for simple factual questions, code generation, code review (use council-review), or tasks needing file modifications.
93context7
Fetch up-to-date documentation for any open-source library or framework. Use when the user asks to look up docs, check an API, find code examples, or verify how a feature works — especially with a specific library name, version migration, or phrases like 'what's the current way to...' or 'the API might have changed'. Also covers setup and configuration docs. Do NOT use for general programming concepts, internal project code, or version lookups (use deps-dev).
86react-web-advanced
Web-specific React patterns for type-safe file-based routing, route-level data loading, server-side rendering, search param validation, code splitting, and list virtualization. Use when building React web apps with route loaders, SSR streaming, validated search params, lazy route splitting, or virtualizing large DOM lists. Do not use for React Native apps — use react-native-advanced instead.
45react-advanced
Advanced React patterns and conventions for data fetching, tables, forms, state machines, client state management, schema validation, and testing. Use when tackling complex React problems — not simple component questions, but multi-concern tasks like server-driven tables with filtering, multi-step wizards, eliminating useEffect, Suspense architecture, choosing between state management approaches, or designing data flow across server/client/URL/form state. Do not use for web-specific routing/SSR or React Native-specific navigation/performance.
45react-native-advanced
React Native and Expo patterns for navigation, data fetching lifecycle, infinite scroll lists, form handling, state persistence, authentication routing, gesture-driven animations, bottom sheets, push notifications, and OTA updates. Use when building Expo/React Native apps that need data prefetching without route loaders, auth guard routing, infinite scroll with FlashList, gesture-driven animations, or native platform integration (push notifications, OTA updates, MMKV persistence). Do not use for React web apps.
44