| name | typescript-type-system |
| description | Deep dive into TypeScript's type system — narrowing, discriminated unions, mapped types, conditional types, type guards, branding, and escape hatches. TRIGGER when: user asks about unknown vs any, type narrowing, discriminated unions, exhaustiveness checking, mapped types, conditional types, infer keyword, user-defined type guards, type branding, companion object pattern, as const, escape hatches, type assertion, how do I narrow a type, how do I write a type predicate, how to create nominal types, how to prevent structural aliasing, make illegal states unrepresentable, illegal states, state machine types, entity lifecycle types, optional field anti-pattern, discriminated union state modeling, interface vs type alias, when to use interface vs type, enum best practices, typescript enum, const enum, readonly property, ReadonlyArray, lazy object initialization, barrel exports, when to use generics, generic naming conventions, no interface prefix. DO NOT USE when: runtime/schema validation at boundaries (parsing external input, API responses) → use `typescript-zod`; plain naming or JS idiom questions → use `javascript`.
|
| user-invocable | false |
TypeScript Type System
Primary reference: TypeScript Handbook — Narrowing and Everyday Types.
Quick Concept Index
| Problem / Topic | Concept |
|---|
| Safely handle API responses / JSON.parse | unknown vs any |
| Narrow a union based on runtime checks | Type narrowing & refinement |
| Route on message type with different shapes | Discriminated unions |
| Model entity lifecycle without invalid states | Make Illegal States Unrepresentable |
| Fail at compile time when a case is missed | Exhaustiveness / assertNever |
Create Partial, Readonly, or custom maps | Mapped types |
Unwrap Promise<T>, filter union members | Conditional types + infer |
| Carry narrowing across function boundaries | User-defined type guards |
Prevent mixing UserId and SessionToken | Type branding (nominal types) |
| Pair a type and utility under one import | Companion object pattern |
| Preserve literal types in config objects | as const / type widening |
| Last-resort override of TypeScript's checks | Escape hatches (as T, !) |
Concepts
unknown vs any — any disables checking; unknown forces narrowing before use. Default to unknown for external data (JSON.parse, API responses, user input). See The unknown type.
function process(value: unknown) {
if (typeof value === "string") value.toUpperCase();
value.toUpperCase();
}
Type narrowing — TypeScript narrows union types through typeof, instanceof, in, equality checks, truthiness, and Array.isArray. Narrowing eliminates impossible branches. See Narrowing.
function format(val: string | number | null) {
if (val === null) return "—";
if (typeof val === "number") return val.toFixed(2);
return val.trim();
}
Discriminated unions — A shared literal tag field (kind, type, status) lets switch/if dispatch on shape. Essential for Redux actions, WebSocket messages, API variants. See Discriminated Unions.
type Event =
| { kind: "login"; userId: string }
| { kind: "logout"; userId: string; reason: string }
| { kind: "error"; message: string };
function handle(e: Event) {
switch (e.kind) {
case "login": return greet(e.userId);
case "logout": return log(e.userId, e.reason);
case "error": return alert(e.message);
}
}
Make Illegal States Unrepresentable — Model each state of an entity lifecycle as its own type in a discriminated union. Eliminates incoherent field combinations that optional fields allow.
Anti-pattern — single type with optional fields permits invalid states:
type Task = {
id: string;
title: string;
status: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | "CANCELLED";
startedAt?: Date;
finishedAt?: Date;
error?: string;
cancelledBy?: string;
};
Fix — one type per state, shared base via intersection:
type AbstractTask = { id: string; title: string; createdAt: Date };
type PendingTask = AbstractTask & { status: "PENDING" };
type InProgressTask = AbstractTask & { status: "IN_PROGRESS"; startedAt: Date };
type CompletedTask = AbstractTask & { status: "COMPLETED"; startedAt: Date; finishedAt: Date };
type FailedTask = AbstractTask & { status: "FAILED"; startedAt: Date; finishedAt: Date; error: string };
type CancelledTask = AbstractTask & { status: "CANCELLED"; cancelledBy: string; cancelledAt: Date };
type Task = PendingTask | InProgressTask | CompletedTask | FailedTask | CancelledTask;
function handleTask(task: Task): void {
switch (task.status) {
case "COMPLETED":
console.log(`Done in ${task.finishedAt.getTime() - task.startedAt.getTime()}ms`);
break;
case "FAILED":
console.error(task.error);
break;
case "CANCELLED":
console.log(`Cancelled by ${task.cancelledBy}`);
break;
}
}
Exhaustiveness checking — assertNever(value: never) produces a compile error when a new union member is added but not handled. Full runnable example: example.md. See Exhaustiveness checking.
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
Mapped types — Transform every key of an existing type: { [K in keyof T]?: T[K] }. Built-ins: Partial, Required, Readonly, Pick, Record. See Mapped Types.
type Flags<T> = { [K in keyof T]: boolean };
Conditional types — Type-level ternary: T extends U ? X : Y. With infer, extract type arguments at the type level. See Conditional Types.
type Unwrap<T> = T extends Promise<infer U> ? U : T;
User-defined type guards — Return value is T to carry narrowing across function boundaries, where TypeScript can't infer the refinement. See User-Defined Type Guards.
function isUser(v: unknown): v is User {
return typeof v === "object" && v !== null && "id" in v;
}
Type branding — Prevents mixing structurally identical types (UserId vs SessionToken) at zero runtime cost. Use unique symbol for full nominal safety.
declare const _brand: unique symbol;
type UserId = string & { readonly [_brand]: "UserId" };
type SessionToken = string & { readonly [_brand]: "SessionToken" };
function createUserId(id: string): UserId { return id as UserId; }
Companion object pattern — Bind the same name to both a type and const value. One import covers annotation and utilities.
as const — Freeze values to literal types. Use on configs/arrays to derive union types only when no type exists yet. If a type already exists, annotate with it instead. See const assertions.
const ROLES = ["admin", "user", "guest"] as const;
type Role = (typeof ROLES)[number];
type Role = "admin" | "user" | "guest";
const ROLES = ["admin", "user", "guest"] as const;
type RoleAgain = (typeof ROLES)[number];
type Role = "admin" | "user" | "guest";
const ROLES: Role[] = ["admin", "user", "guest"];
⚠️ See rule: rules/favor-existing-types-over-as-const.md
Escape hatches — as T, !, !: override TypeScript checks. Last resort; frequent use signals refactoring needed.
Interface vs Type Alias
Use interface when | Use type when |
|---|
Defining an object shape others will extend or implement | Unions and intersections |
| You need declaration merging (augmenting third-party types) | Mapped/conditional/utility types |
| Modeling a class contract | Simple semantic alias (type UserId = string) |
interface Repository<T> {
findById(id: string): T | undefined;
save(entity: T): void;
}
type Result<T> = { ok: true; value: T } | { ok: false; error: Error };
Do NOT prefix interfaces with I. See rules/no-interface-prefix.md.
Generics: when (not) to use
Don't use generics at a single location — it provides no type safety over any:
declare function parse<T>(name: string): T;
function identity<T>(x: T): T { return x; }
function reverse<T>(items: T[]): T[] { return [...items].reverse(); }
Use descriptive names for multi-parameter generics:
class Dictionary<TKey, TValue> {
get(key: TKey): TValue | undefined { ... }
set(key: TKey, value: TValue): void { ... }
}
Enums best practices
enum Status {
Inactive = 1,
Active = 2,
Pending = 3,
}
enum DocumentType {
Passport = 'passport',
Visa = 'passport_visa',
DriversLicense = 'drivers_license',
}
const enum Direction { Up = 'UP', Down = 'DOWN' }
Lazy object initialization anti-pattern
Avoid initializing an empty object and adding properties later — TypeScript infers {} and later assignments fail:
let config = {};
config.host = "localhost";
const config = { host: "localhost", port: 3000 };
let config: Config = { host: "localhost", port: 3000 };
→ Full examples with runnable code: references/type-system.md
→ Exhaustiveness-checking example: example.md
Benchmark
Scenario: .benchmarks/scenarios/typescript-001-illegal-states.md · Run: 2026-06-14
| Model | Without | With | Delta |
|---|
| claude-opus-4-8 | 100% | 100% | +0% |
| claude-sonnet-4-6 | 83% | 100% | +17% |
| claude-haiku-4-5 | 100% | 100% | +0% |
SOFT PASS (ceiling effect — frontier baselines already produce textbook discriminated unions). Only lift is shared-base on sonnet.
Scenario: .benchmarks/scenarios/typescript-002-state-transitions.md · Run: 2026-06-25 (N=3 averages) (harder variant — typed transitions + branded ids)
| Model | Without | With | Delta |
|---|
| claude-opus-4-8 | 67% | 67% | +0% |
| claude-sonnet-4-6 | 72% | 67% | −5% |
| claude-haiku-4-5 | 56% | 67% | +11% |
NEUTRAL (re-run 2026-06-25, N=3). The earlier single-run haiku −16% was noise — across three samples haiku is +11% (56→67) with no real regression on any model (sonnet −5% is one criterion of within-run variance). The scenario is genuinely hard: even with the skill, opus caps at 67% (4/6), so it doesn't yet differentiate frontier models. No triage needed; to push the ceiling, sharpen the compile-time-transition guidance. Gate per skill-optimizer/release-gates.md.