# Type System

> Deep dive into TypeScript's type system — structural typing, type erasure, narrowing, discriminated unions, mapped/conditional types, type guards, branding, and escape hatches. Reference bundle for the `typescript` skill; not independently triggered.

- Skill: `bsene/type-system` (Agent Skill)
- Install (CLI): `npx skillmds@latest add bsene/type-system`
- Raw SKILL.md: https://api.skillmd.com/api/skills/bsene/type-system/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: bsene (https://skillmd.com/u/bsene)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/bsene/type-system

---


# TypeScript Type System

Primary reference: [TypeScript docs](https://www.typescriptlang.org/docs/). Section links below point at the exact page each concept comes from.

## Quick Concept Index

| Problem / Topic                                                 | Concept                                |
| --------------------------------------------------------------- | -------------------------------------- |
| Safely handle API responses / JSON.parse                        | `unknown` vs `any`                     |
| Reuse an object without tying it to a class name                | Structural typing                      |
| Explain why static types do not validate runtime data           | Type erasure                           |
| 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`            |
| Why a callback with a wider parameter type is rejected (TS2345) | Variance (covariance / contravariance) |
| 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

**Structural typing** — Compatibility is determined by shape, not by nominal inheritance. Prefer the narrowest useful shape; a name alone does not prevent mixing semantically different values. Use branding for domain IDs.

Avoid empty structures; they accept almost every value:

```typescript
// Bad — no contract
interface Empty {}
type Anything = {};
```

See _Type branding_ below for the full example.

**Type erasure** — Static `T` values do not survive into emitted JavaScript as runtime checks. Validate data that crosses a runtime boundary with Zod — see `../zod/SKILL.md`.

**`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](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-0.html#new-unknown-top-type).

**Type narrowing** — TypeScript narrows union types through `typeof`, `instanceof`, `in`, equality checks, truthiness, and `Array.isArray`. Narrowing eliminates impossible branches. See [Narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html).

**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](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions).

**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:

```typescript
type Task = {
  id: string;
  title: string;
  status: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | "CANCELLED";
  startedAt?: Date;
  finishedAt?: Date;
  error?: string;
  cancelledBy?: string;
};
// Compiles fine: { status: "PENDING", finishedAt: new Date() } — incoherent
```

Fix — one type per state, shared base via intersection:

```typescript
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:

```typescript
type Shape =
  { kind: "circle"; radius: number } | { kind: "square"; side: number };

function assertNever(x: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    default:
      return assertNever(shape); // compile error if a new Shape variant is added and unhandled
  }
}
```

See [Exhaustiveness checking](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#exhaustiveness-checking).

**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](https://www.typescriptlang.org/docs/handbook/2/mapped-types.html).

**Conditional types** — Type-level ternary: `T extends U ? X : Y`. With `infer`, extract type arguments at the type level. See [Conditional Types](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html).

**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](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates).

**Variance** — Functions are covariant in their return type and contravariant in their parameters, so a handler with a narrower parameter type is not assignable where a wider one is expected (the classic TS2345). Under [strictFunctionTypes](https://www.typescriptlang.org/tsconfig/#strictFunctionTypes) this is checked only for function-_property_ syntax — method syntax stays bivariant — so declare callback-typed options as properties. The docs demonstrate both by example but never name or generalize the rule.

**Type branding** — Prevents mixing structurally identical types (`UserId` vs `SessionToken`) at zero runtime cost. Use `unique symbol` for full nominal safety.

```typescript
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;
}
// createUserId(rawToken)   → compile error — token ≠ 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, but only when no type exists yet for what you're deriving. If a type already exists, annotate with it instead — see `../rules/favor-existing-types-over-as-const.md` for the full rationale and examples.

**Escape hatches** — `as T`, `!`, `!:` override TypeScript checks. Last resort; frequent use signals refactoring needed. See `../rules/avoid-type-assertions.md` for banned patterns and alternatives.

---

## 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`) |

```typescript
// interface — extendable contract
interface Repository<T> {
  findById(id: string): T | undefined;
  save(entity: T): void;
}

// type — union (can't be done with interface)
type Result<T> = { ok: true; value: T } | { ok: false; error: Error };
```

**Why `interface extends` over `type &` for composition:** TypeScript caches an interface's resolved shape by name, so it doesn't get recomputed each time it's referenced; a `type` intersection is recomputed at each use site. Interfaces also catch conflicting property types across the merged shapes as a compile error, where an intersection can silently collapse the conflicting property to `never`. This gap narrows a lot under the Go-ported TypeScript 7 compiler, since the overall baseline gets much faster — but `interface extends` remains the safer default for composed object shapes; reach for `type` when you need unions, primitives, tuples, or mapped/conditional types that `interface` can't express.

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`:

```typescript
// Bad — T appears only in return; equivalent to returning any
declare function parse<T>(name: string): T;

// Good — T appears in both parameter and return (meaningful constraint)
function identity<T>(x: T): T {
  return x;
}
function reverse<T>(items: T[]): T[] {
  return [...items].reverse();
}
```

Use descriptive names for multi-parameter generics:

```typescript
class Dictionary<TKey, TValue> {
  get(key: TKey): TValue | undefined { ... }
  set(key: TKey, value: TValue): void { ... }
}
```

---

## Enums best practices

```typescript
enum Status {
  Inactive = 1, // start at 1 — avoids falsy 0 bugs
  Active = 2,
  Pending = 3,
}

enum DocumentType {
  // string enums — better logs and API interop
  Passport = "passport",
  Visa = "passport_visa",
  DriversLicense = "drivers_license",
}

const enum Direction {
  Up = "UP",
  Down = "DOWN",
} // zero runtime cost (inlined)
```

---

## Lazy object initialization anti-pattern

Avoid initializing an empty object and adding properties later — TypeScript infers `{}` and later assignments fail:

```typescript
// Bad
let config = {};
config.host = "localhost"; // Error: property 'host' does not exist on type '{}'

// Good — initialize all properties together
const config = { host: "localhost", port: 3000 };

// Or annotate first
let config: Config = { host: "localhost", port: 3000 };
```

---

## Benchmark

Scenario: `.benchmarks/scenarios/typescript-001-illegal-states.md` · Run: 2026-08-31 · Log: `.benchmarks/runs/2026-08-31/typescript-001-illegal-states.json`

| Model             | Without | With | Delta |
| ----------------- | ------- | ---- | ----- |
| claude-opus-4-8   | 67%     | 100% | +33%  |
| claude-sonnet-4-6 | 67%     | 83%  | +16%  |
| claude-haiku-4-5  | 67%     | 83%  | +16%  |

> **PASS (run 2026-08-31)**. Gains on all models — the 2026-06-14 SOFT PASS ceiling effect is partly overcome. Gate per `.agents/skills/skill-optimizer/rules/release-gates.md`.

Scenario: `.benchmarks/scenarios/typescript-002-state-transitions.md` · Run: 2026-08-31 · Log: `.benchmarks/runs/2026-08-31/typescript-002-state-transitions.json`

| Model             | Without | With | Delta |
| ----------------- | ------- | ---- | ----- |
| claude-opus-4-8   | 83%     | 83%  | +0%   |
| claude-sonnet-4-6 | 83%     | 83%  | +0%   |
| claude-haiku-4-5  | 67%     | 83%  | +16%  |

> **SOFT PASS (run 2026-08-31)**. Haiku +16 (67→83); opus at ceiling. Sonnet's 2026-06-25 N=3 −5% did not reproduce (0 here). Gate per `.agents/skills/skill-optimizer/rules/release-gates.md`.

