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