Lean TypeScript
Write less code that does more. Every line is a liability — TypeScript's type system
is your primary tool for eliminating runtime bloat.
10 Golden Rules
- Files under 150 lines — forces modular thinking; split by domain concern
- Functions over classes — unless state + methods genuinely belong together
- Interfaces over class hierarchies — simpler contracts, easier testing
- Result types over exceptions — explicit error flow, no hidden control jumps
- Zod at boundaries only — trust static types internally, validate external data
- Named exports only — better tree-shaking and refactoring
- Feature folders — group by domain (
payments/, auth/), not layer (services/, models/)
- Pure core, I/O shell — business logic as pure functions, I/O at the edges
- pnpm + tsx + Vitest — the 2026 standard toolchain
- Reject LLM bloat — stay vigilant against additive patterns
Anti-Bloat: Patterns to Reject
LLMs default to these — catch and remove them:
utils.ts or helpers.ts — be specific about what the module does
- Try-catch wrapping every async function — handle errors at boundaries
console.log scattered everywhere — use structured logging or nothing
- Runtime type checking what TypeScript already guarantees — trust your types
- Classes with only a constructor and one method — use a function
- Default exports — kills tree-shaking, worse DX for refactoring
- JSDoc that restates the type signature — the types are the docs
- Manager → Service → Handler hierarchies — flatten to functions + interfaces
- Config options for things that never change — hardcode them
any type — always find the real type or use unknown + narrowing
Core Patterns
Result Type (railway-oriented)
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });
For heavier use cases, neverthrow (~2KB gz) provides ResultAsync with map/andThen chaining. Only reach for Effect-TS if you need its full fiber runtime — it carries ~5-10KB overhead and requires full buy-in.
Dependency Injection via Interfaces
interface PaymentProvider {
charge(amount: number, token: string): Promise<string>;
}
// Swap implementations without changing callers
async function processPayment(
provider: PaymentProvider, amount: number, token: string
): Promise<string> {
return provider.charge(amount, token);
}
Zod at Boundaries
import { z } from 'zod';
// Validate external data at the edge
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
}).strict();
type CreateUser = z.infer<typeof CreateUserSchema>;
// Internal functions trust types — no re-validation
function saveUser(data: CreateUser): Promise<User> {
return db.users.insert(data);
}
Branded Types for Domain Safety
const UserId = z.string().uuid().brand('UserId');
type UserId = z.infer<typeof UserId>;
// Compiler prevents mixing UserId with OrderId
function getUser(id: UserId): Promise<User> { /* ... */ }
Functional Core, Imperative Shell
// CORE — pure, easily tested, no I/O
function calculateBudget(clicks: number, cpc: number, mult: number): number {
return clicks * cpc * mult;
}
// SHELL — handles I/O, minimal logic
async function updateBudgetEndpoint(req: Request): Promise<Response> {
const metrics = await fetchMetrics(req.params.id);
const budget = calculateBudget(metrics.clicks, metrics.cpc, 1.2);
await saveBudget(req.params.id, budget);
return Response.json({ budget });
}
Type-Safe Event System
type EventMap = {
'user.created': { id: string; email: string };
'order.completed': { id: string; total: number };
};
type EventHandler<T extends keyof EventMap> = (data: EventMap[T]) => void;
Project Structure Template
src/
├── payments/ # Feature domain
│ ├── types.ts # Domain types + Zod schemas
│ ├── stripe.ts # Provider implementation
│ ├── api.ts # HTTP routes
│ └── events.ts # Domain events
├── auth/
│ ├── types.ts
│ ├── oauth.ts
│ └── api.ts
└── shared/ # Only truly shared code
├── result.ts # Result type utilities
└── config.ts # App configuration
Not this: models/ + services/ + controllers/ + utils/ (layer-based = coupling magnet)
Toolchain & Config
For detailed toolchain setup, library choices, and debloating strategies, read the
reference files in references/:
references/toolchain.md — pnpm, tsx, Vitest 4, Biome 2.3, strict tsconfig, package.json template
references/patterns.md — advanced DI patterns (Awilix, InversifyJS), testing strategies, property-based testing with fast-check, architecture testing
references/debloating.md — library replacements (es-toolkit, Zod v4 mini, Temporal API), dead code elimination with Knip, bundle analysis
Quick Decision Guide
| Question |
Answer |
| Class or function? |
Function, unless genuine state + methods |
| Zod or manual validation? |
Zod at boundaries, nothing internally |
| Effect-TS or neverthrow? |
neverthrow unless you need the full runtime |
| Jest or Vitest? |
Vitest 4 (10-20x faster) |
| ESLint+Prettier or Biome? |
Biome 2.3 (19-100x faster, single config) |
| npm, yarn, or pnpm? |
pnpm (faster, disk-efficient, catalogs) |
| lodash or es-toolkit? |
es-toolkit (97% smaller, 2-3x faster) |
| moment/dayjs or Temporal? |
Temporal API (native, 0KB) if targeting modern runtime |
| Default or named export? |
Named. Always. |
1---2name: lean-typescript3description: >4 TypeScript 2026 best practices for writing small, powerful codebases. Anti-bloat5 patterns, schema-first validation, modern toolchain (pnpm, tsx, Vitest 4, Biome),6 and architectural patterns that both humans and AI agents can reason about. Use this7 skill whenever writing TypeScript, reviewing TypeScript code, setting up a new TS8 project, choosing libraries, configuring tsconfig, debloating a codebase, or when9 the user mentions: TypeScript, Zod, Vitest, pnpm, "too much code", "clean up",10 "reduce bundle", "strict mode", "type safety", "modern stack", "2026 best practices",11 or any request involving TypeScript architecture, tooling, or code quality.12---1314# Lean TypeScript1516Write less code that does more. Every line is a liability — TypeScript's type system17is your primary tool for eliminating runtime bloat.1819---2021## 10 Golden Rules22231. **Files under 150 lines** — forces modular thinking; split by domain concern242. **Functions over classes** — unless state + methods genuinely belong together253. **Interfaces over class hierarchies** — simpler contracts, easier testing264. **Result types over exceptions** — explicit error flow, no hidden control jumps275. **Zod at boundaries only** — trust static types internally, validate external data286. **Named exports only** — better tree-shaking and refactoring297. **Feature folders** — group by domain (`payments/`, `auth/`), not layer (`services/`, `models/`)308. **Pure core, I/O shell** — business logic as pure functions, I/O at the edges319. **pnpm + tsx + Vitest** — the 2026 standard toolchain3210. **Reject LLM bloat** — stay vigilant against additive patterns3334---3536## Anti-Bloat: Patterns to Reject3738LLMs default to these — catch and remove them:3940- `utils.ts` or `helpers.ts` — be specific about what the module does41- Try-catch wrapping every async function — handle errors at boundaries42- `console.log` scattered everywhere — use structured logging or nothing43- Runtime type checking what TypeScript already guarantees — trust your types44- Classes with only a constructor and one method — use a function45- Default exports — kills tree-shaking, worse DX for refactoring46- JSDoc that restates the type signature — the types *are* the docs47- Manager → Service → Handler hierarchies — flatten to functions + interfaces48- Config options for things that never change — hardcode them49- `any` type — always find the real type or use `unknown` + narrowing5051---5253## Core Patterns5455### Result Type (railway-oriented)5657```typescript58type Result<T, E = Error> =59 | { ok: true; value: T }60 | { ok: false; error: E };6162const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value });63const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });64```6566For heavier use cases, `neverthrow` (~2KB gz) provides `ResultAsync` with `map`/`andThen` chaining. Only reach for Effect-TS if you need its full fiber runtime — it carries ~5-10KB overhead and requires full buy-in.6768### Dependency Injection via Interfaces6970```typescript71interface PaymentProvider {72 charge(amount: number, token: string): Promise<string>;73}7475// Swap implementations without changing callers76async function processPayment(77 provider: PaymentProvider, amount: number, token: string78): Promise<string> {79 return provider.charge(amount, token);80}81```8283### Zod at Boundaries8485```typescript86import { z } from 'zod';8788// Validate external data at the edge89const CreateUserSchema = z.object({90 email: z.string().email(),91 name: z.string().min(1).max(100),92}).strict();9394type CreateUser = z.infer<typeof CreateUserSchema>;9596// Internal functions trust types — no re-validation97function saveUser(data: CreateUser): Promise<User> {98 return db.users.insert(data);99}100```101102### Branded Types for Domain Safety103104```typescript105const UserId = z.string().uuid().brand('UserId');106type UserId = z.infer<typeof UserId>;107108// Compiler prevents mixing UserId with OrderId109function getUser(id: UserId): Promise<User> { /* ... */ }110```111112### Functional Core, Imperative Shell113114```typescript115// CORE — pure, easily tested, no I/O116function calculateBudget(clicks: number, cpc: number, mult: number): number {117 return clicks * cpc * mult;118}119120// SHELL — handles I/O, minimal logic121async function updateBudgetEndpoint(req: Request): Promise<Response> {122 const metrics = await fetchMetrics(req.params.id);123 const budget = calculateBudget(metrics.clicks, metrics.cpc, 1.2);124 await saveBudget(req.params.id, budget);125 return Response.json({ budget });126}127```128129### Type-Safe Event System130131```typescript132type EventMap = {133 'user.created': { id: string; email: string };134 'order.completed': { id: string; total: number };135};136137type EventHandler<T extends keyof EventMap> = (data: EventMap[T]) => void;138```139140---141142## Project Structure Template143144```145src/146├── payments/ # Feature domain147│ ├── types.ts # Domain types + Zod schemas148│ ├── stripe.ts # Provider implementation149│ ├── api.ts # HTTP routes150│ └── events.ts # Domain events151├── auth/152│ ├── types.ts153│ ├── oauth.ts154│ └── api.ts155└── shared/ # Only truly shared code156 ├── result.ts # Result type utilities157 └── config.ts # App configuration158```159160**Not this:** `models/` + `services/` + `controllers/` + `utils/` (layer-based = coupling magnet)161162---163164## Toolchain & Config165166For detailed toolchain setup, library choices, and debloating strategies, read the167reference files in `references/`:168169- **`references/toolchain.md`** — pnpm, tsx, Vitest 4, Biome 2.3, strict tsconfig, package.json template170- **`references/patterns.md`** — advanced DI patterns (Awilix, InversifyJS), testing strategies, property-based testing with fast-check, architecture testing171- **`references/debloating.md`** — library replacements (es-toolkit, Zod v4 mini, Temporal API), dead code elimination with Knip, bundle analysis172173---174175## Quick Decision Guide176177| Question | Answer |178|----------|--------|179| Class or function? | Function, unless genuine state + methods |180| Zod or manual validation? | Zod at boundaries, nothing internally |181| Effect-TS or neverthrow? | neverthrow unless you need the full runtime |182| Jest or Vitest? | Vitest 4 (10-20x faster) |183| ESLint+Prettier or Biome? | Biome 2.3 (19-100x faster, single config) |184| npm, yarn, or pnpm? | pnpm (faster, disk-efficient, catalogs) |185| lodash or es-toolkit? | es-toolkit (97% smaller, 2-3x faster) |186| moment/dayjs or Temporal? | Temporal API (native, 0KB) if targeting modern runtime |187| Default or named export? | Named. Always. |