6 min read
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
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
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
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
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
{
"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
{
"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.
.claude/skills/typescript-conventions/SKILL.mdDrop 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