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: TypeScript 2026 best practices for writing small, powerful codebases. Anti-bloat patterns, schema-first validation, modern toolchain (pnpm, tsx, Vitest 4, Biome), and architectural patterns that both humans and AI agents can reason about. Use this skill whenever writing TypeScript, reviewing TypeScript code, setting up a new TS project, choosing libraries, configuring tsconfig, debloating a codebase, or when the user mentions: TypeScript, Zod, Vitest, pnpm, "too much code", "clean up", "reduce bundle", "strict mode", "type safety", "modern stack", "2026 best practices", or any request involving TypeScript architecture, tooling, or code quality.4---56# Lean TypeScript78Write less code that does more. Every line is a liability — TypeScript's type system9is your primary tool for eliminating runtime bloat.1011---1213## 10 Golden Rules14151. **Files under 150 lines** — forces modular thinking; split by domain concern162. **Functions over classes** — unless state + methods genuinely belong together173. **Interfaces over class hierarchies** — simpler contracts, easier testing184. **Result types over exceptions** — explicit error flow, no hidden control jumps195. **Zod at boundaries only** — trust static types internally, validate external data206. **Named exports only** — better tree-shaking and refactoring217. **Feature folders** — group by domain (`payments/`, `auth/`), not layer (`services/`, `models/`)228. **Pure core, I/O shell** — business logic as pure functions, I/O at the edges239. **pnpm + tsx + Vitest** — the 2026 standard toolchain2410. **Reject LLM bloat** — stay vigilant against additive patterns2526---2728## Anti-Bloat: Patterns to Reject2930LLMs default to these — catch and remove them:3132- `utils.ts` or `helpers.ts` — be specific about what the module does33- Try-catch wrapping every async function — handle errors at boundaries34- `console.log` scattered everywhere — use structured logging or nothing35- Runtime type checking what TypeScript already guarantees — trust your types36- Classes with only a constructor and one method — use a function37- Default exports — kills tree-shaking, worse DX for refactoring38- JSDoc that restates the type signature — the types *are* the docs39- Manager → Service → Handler hierarchies — flatten to functions + interfaces40- Config options for things that never change — hardcode them41- `any` type — always find the real type or use `unknown` + narrowing4243---4445## Core Patterns4647### Result Type (railway-oriented)4849```typescript50type Result<T, E = Error> =51 | { ok: true; value: T }52 | { ok: false; error: E };5354const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value });55const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });56```5758For 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.5960### Dependency Injection via Interfaces6162```typescript63interface PaymentProvider {64 charge(amount: number, token: string): Promise<string>;65}6667// Swap implementations without changing callers68async function processPayment(69 provider: PaymentProvider, amount: number, token: string70): Promise<string> {71 return provider.charge(amount, token);72}73```7475### Zod at Boundaries7677```typescript78import { z } from 'zod';7980// Validate external data at the edge81const CreateUserSchema = z.object({82 email: z.string().email(),83 name: z.string().min(1).max(100),84}).strict();8586type CreateUser = z.infer<typeof CreateUserSchema>;8788// Internal functions trust types — no re-validation89function saveUser(data: CreateUser): Promise<User> {90 return db.users.insert(data);91}92```9394### Branded Types for Domain Safety9596```typescript97const UserId = z.string().uuid().brand('UserId');98type UserId = z.infer<typeof UserId>;99100// Compiler prevents mixing UserId with OrderId101function getUser(id: UserId): Promise<User> { /* ... */ }102```103104### Functional Core, Imperative Shell105106```typescript107// CORE — pure, easily tested, no I/O108function calculateBudget(clicks: number, cpc: number, mult: number): number {109 return clicks * cpc * mult;110}111112// SHELL — handles I/O, minimal logic113async function updateBudgetEndpoint(req: Request): Promise<Response> {114 const metrics = await fetchMetrics(req.params.id);115 const budget = calculateBudget(metrics.clicks, metrics.cpc, 1.2);116 await saveBudget(req.params.id, budget);117 return Response.json({ budget });118}119```120121### Type-Safe Event System122123```typescript124type EventMap = {125 'user.created': { id: string; email: string };126 'order.completed': { id: string; total: number };127};128129type EventHandler<T extends keyof EventMap> = (data: EventMap[T]) => void;130```131132---133134## Project Structure Template135136```137src/138├── payments/ # Feature domain139│ ├── types.ts # Domain types + Zod schemas140│ ├── stripe.ts # Provider implementation141│ ├── api.ts # HTTP routes142│ └── events.ts # Domain events143├── auth/144│ ├── types.ts145│ ├── oauth.ts146│ └── api.ts147└── shared/ # Only truly shared code148 ├── result.ts # Result type utilities149 └── config.ts # App configuration150```151152**Not this:** `models/` + `services/` + `controllers/` + `utils/` (layer-based = coupling magnet)153154---155156## Toolchain & Config157158For detailed toolchain setup, library choices, and debloating strategies, read the159reference files in `references/`:160161- **`references/toolchain.md`** — pnpm, tsx, Vitest 4, Biome 2.3, strict tsconfig, package.json template162- **`references/patterns.md`** — advanced DI patterns (Awilix, InversifyJS), testing strategies, property-based testing with fast-check, architecture testing163- **`references/debloating.md`** — library replacements (es-toolkit, Zod v4 mini, Temporal API), dead code elimination with Knip, bundle analysis164165---166167## Quick Decision Guide168169| Question | Answer |170|----------|--------|171| Class or function? | Function, unless genuine state + methods |172| Zod or manual validation? | Zod at boundaries, nothing internally |173| Effect-TS or neverthrow? | neverthrow unless you need the full runtime |174| Jest or Vitest? | Vitest 4 (10-20x faster) |175| ESLint+Prettier or Biome? | Biome 2.3 (19-100x faster, single config) |176| npm, yarn, or pnpm? | pnpm (faster, disk-efficient, catalogs) |177| lodash or es-toolkit? | es-toolkit (97% smaller, 2-3x faster) |178| moment/dayjs or Temporal? | Temporal API (native, 0KB) if targeting modern runtime |179| Default or named export? | Named. Always. |