All topics

TypeScript Conventions

Types that catch real bugs instead of decorating the code — discriminated unions, parsed boundaries, and no `any` escape hatches.

Principles

Make illegal states unrepresentable

Four booleans describe sixteen states, of which maybe three are real. A discriminated union describes exactly the states that exist, and the compiler stops you from rendering a spinner next to an error.

Parse at the boundary, trust afterwards

An API response typed as `User` is a claim, not a fact — the server can send anything. Validate once where data enters, then let the rest of the codebase trust the type. This is where TypeScript actually prevents production incidents.

`unknown` at the edges, never `any`

`any` disables checking silently and spreads through every value it touches. `unknown` forces a narrowing decision at the one place where you actually have the context to make it.

Infer types from values, don't duplicate them

A hand-written type next to a runtime schema is a second source of truth that drifts. Derive one from the other so they cannot disagree.

During a JS-to-TS migration, ratchet strictness

Turning on `strict` across a large legacy codebase produces thousands of errors and one very demoralized team. Enable per-flag, fix, and lock in the gain so the baseline can only improve.

Patterns

What goes wrong, what to do instead, and why the difference matters.

Modelling async state

Avoid

ts
type State = {
  isLoading: boolean;
  data: User | null;
  error: Error | null;
};

// Sixteen possible states. What does
// { isLoading: true, data: user, error: err } mean?

Nothing prevents contradictory combinations, so every consumer re-implements its own guesswork.

Prefer

ts
type State =
  | { status: "pending" }
  | { status: "success"; data: User }
  | { status: "error"; error: Error };

// Narrowing gives you the right fields, and only those.
if (state.status === "success") {
  console.log(state.data.name);  // `error` isn't even in scope
}

Three states, all real. The compiler routes each branch to exactly the data it owns.

Trusting the network

Avoid

ts
async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  // A claim, not a check. If the API renamed a field,
  // this blows up somewhere far away from here.
  return res.json() as Promise<User>;
}

The cast makes the compiler stop asking, which is the opposite of what you want at a trust boundary.

Prefer

ts
const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
});

// One source of truth — the type is derived from the schema.
export type User = z.infer<typeof UserSchema>;

export async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  // Fails loudly here, where the context to debug it exists.
  return UserSchema.parse(await res.json());
}

Validated once at the edge, and the type can never drift from the schema.

Migration strictness ratchet

Avoid

json
{
  "compilerOptions": {
    // Day one of the migration on a 200k-line codebase.
    "strict": true
  }
}
// 4,000 errors. The flag gets turned off by Friday.

All-or-nothing strictness on a legacy codebase gets reverted, and the team learns that types are a nuisance.

Prefer

json
{
  "compilerOptions": {
    "noImplicitAny": true,          // step 1 — landed
    "strictNullChecks": true,       // step 2 — landed
    "strictFunctionTypes": true,    // step 3 — in progress
    "noUncheckedIndexedAccess": false  // step 4 — queued
  }
}

One flag at a time, each locked in by CI. Strictness only ever moves forward.

Review checklist

What I look for when reviewing a pull request that touches this area.

  • No `any` in application code; `unknown` plus narrowing at the edges.
  • Async and form state modelled as discriminated unions.
  • Every network and storage boundary is parsed, not cast.
  • Types are inferred from schemas rather than written twice.
  • `strict` is on, or a written ratchet plan says which flag lands next.
  • `as` casts appear only after a real runtime check.

Take the skill

Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.

Download
.claude/skills/typescript-conventions/SKILL.md

Drop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.

---
name: typescript-conventions
description: >-
  TypeScript patterns for React and React Native: discriminated unions over
  boolean soup, runtime parsing at boundaries, no-any policy, and
  incremental strictness during JS-to-TS migration. Use when adding types or
  migrating a codebase.
argument-hint: "[file or module]"
license: MIT
metadata:
  author: John Felix Lim
  version: "1.0.0"
---
# TypeScript Conventions

Apply when writing new types, reviewing type changes, or migrating JavaScript to TypeScript. The two rules with the highest payoff are: model state as unions, and parse at every trust boundary.

## Rules

1. **Make illegal states unrepresentable** — Four booleans describe sixteen states, of which maybe three are real. A discriminated union describes exactly the states that exist, and the compiler stops you from rendering a spinner next to an error.
2. **Parse at the boundary, trust afterwards** — An API response typed as `User` is a claim, not a fact — the server can send anything. Validate once where data enters, then let the rest of the codebase trust the type. This is where TypeScript actually prevents production incidents.
3. **`unknown` at the edges, never `any`** — `any` disables checking silently and spreads through every value it touches. `unknown` forces a narrowing decision at the one place where you actually have the context to make it.
4. **Infer types from values, don't duplicate them** — A hand-written type next to a runtime schema is a second source of truth that drifts. Derive one from the other so they cannot disagree.
5. **During a JS-to-TS migration, ratchet strictness** — Turning on `strict` across a large legacy codebase produces thousands of errors and one very demoralized team. Enable per-flag, fix, and lock in the gain so the baseline can only improve.

## Patterns

### Modelling async state

**Avoid** — Nothing prevents contradictory combinations, so every consumer re-implements its own guesswork.

```ts
type State = {
  isLoading: boolean;
  data: User | null;
  error: Error | null;
};

// Sixteen possible states. What does
// { isLoading: true, data: user, error: err } mean?
```

**Prefer** — Three states, all real. The compiler routes each branch to exactly the data it owns.

```ts
type State =
  | { status: "pending" }
  | { status: "success"; data: User }
  | { status: "error"; error: Error };

// Narrowing gives you the right fields, and only those.
if (state.status === "success") {
  console.log(state.data.name);  // `error` isn't even in scope
}
```

### Trusting the network

**Avoid** — The cast makes the compiler stop asking, which is the opposite of what you want at a trust boundary.

```ts
async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  // A claim, not a check. If the API renamed a field,
  // this blows up somewhere far away from here.
  return res.json() as Promise<User>;
}
```

**Prefer** — Validated once at the edge, and the type can never drift from the schema.

```ts
const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
});

// One source of truth — the type is derived from the schema.
export type User = z.infer<typeof UserSchema>;

export async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  // Fails loudly here, where the context to debug it exists.
  return UserSchema.parse(await res.json());
}
```

### Migration strictness ratchet

**Avoid** — All-or-nothing strictness on a legacy codebase gets reverted, and the team learns that types are a nuisance.

```json
{
  "compilerOptions": {
    // Day one of the migration on a 200k-line codebase.
    "strict": true
  }
}
// 4,000 errors. The flag gets turned off by Friday.
```

**Prefer** — One flag at a time, each locked in by CI. Strictness only ever moves forward.

```json
{
  "compilerOptions": {
    "noImplicitAny": true,          // step 1 — landed
    "strictNullChecks": true,       // step 2 — landed
    "strictFunctionTypes": true,    // step 3 — in progress
    "noUncheckedIndexedAccess": false  // step 4 — queued
  }
}
```

## Review checklist

- [ ] No `any` in application code; `unknown` plus narrowing at the edges.
- [ ] Async and form state modelled as discriminated unions.
- [ ] Every network and storage boundary is parsed, not cast.
- [ ] Types are inferred from schemas rather than written twice.
- [ ] `strict` is on, or a written ratchet plan says which flag lands next.
- [ ] `as` casts appear only after a real runtime check.

---

From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim