Effective TypeScript
Acknowledgement: Shared by Peter Bamuhigire, techguypeter.com, +256 784 464178.
Use When
- Use when writing production TypeScript — clean code idioms, effective-TS items, strict tsconfig, migration from JS, build performance, testing, and anti-patterns. Load
references/typescript-mastery.mdfor type-system depth andreferences/typescript-design-patterns.mdfor GoF patterns.
Evidence Produced
| Category | Artifact | Format | Example |
|---|---|---|---|
| Correctness | Strict tsconfig + Zod boundary register | Markdown doc covering strictness flags applied and Zod schemas at module boundaries | docs/ts/strict-config-register.md |
References
- Use the
references/directory for deep detail after reading the core workflow below.
Production-grade TypeScript beyond the type system. Every rule here lifts real code quality, not just appeases the compiler.
Prerequisite: Load references/typescript-mastery.md for type-system depth. Use this skill for day-to-day production idioms.
When this skill applies
- Code review on TypeScript PRs.
- Starting a new TypeScript project (tsconfig, linting, test setup).
- Migrating a JavaScript codebase to TypeScript gradually.
- Eliminating "any" drift in an existing TS codebase.
- Tuning TypeScript build for large monorepos.
Non-negotiables
strict: trueplusnoUncheckedIndexedAccess,exactOptionalPropertyTypes,noImplicitOverride.- Never use
any. Useunknownat boundaries and narrow. - Never cast with
asunless there is no better option; neveras unknown as T. - Validate every external input with Zod (API responses, env vars, form data, queue payloads).
- Prefer
uniontypes overenumunless integer values and bidirectional lookup are needed. - Use discriminated unions with exhaustive
switchandassertNever. - Never throw strings. Throw
Errorsubclasses. - Model absence with
null/undefinedintentionally — don't mix. readonlyon inputs by default.- Error handling — prefer Result/Either for expected failures at boundaries.
tsconfig for production
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"useUnknownInCatchVariables": true
}
}
strict alone is not enough. noUncheckedIndexedAccess catches real bugs (arr[i] is T | undefined). exactOptionalPropertyTypes distinguishes { x?: number } from { x?: number | undefined }. See references/tsconfig-production.md.
Core effective-TS items
Prefer types that narrow your values:
// BAD - primitive obsession
function sendEmail(to: string, subject: string, body: string) { ... }
// GOOD - branded types
type Email = string & { readonly __brand: "Email" };
type Subject = string & { readonly __brand: "Subject" };
function parseEmail(raw: string): Email | null { ... }
Discriminated unions + exhaustive match:
type Event =
| { kind: "login"; userId: string }
| { kind: "logout"; userId: string; at: Date }
| { kind: "purchase"; userId: string; amount: number };
function handle(e: Event): string {
switch (e.kind) {
case "login": return `login ${e.userId}`;
case "logout": return `logout ${e.userId}`;
case "purchase": return `bought ${e.amount}`;
default: return assertNever(e);
}
}
function assertNever(x: never): never { throw new Error(`unhandled: ${JSON.stringify(x)}`); }
Narrow unknown before use — never silently trust it.
See references/effective-ts-items.md for the full catalogue (~40 items).
Clean code in TS
- Small functions — one reason to change. Functions over 40 lines or 3 levels of nesting: split.
- Names carry intent —
isActive(user)notcheck(user);loadUser(id)notget(id). - Comments explain WHY — TS types explain WHAT. Don't duplicate.
- SOLID in TS — Single Responsibility, Interface Segregation, Dependency Inversion via constructor injection or factory functions.
- Immutability —
readonly,as const,Readonly<T>, spread instead of mutate. - Pure functions where possible — easier to test, reason about, memoise.
See references/clean-code-ts.md.
Error handling — Result/Either at boundaries
Throwing is fine inside a module; at boundaries (API handlers, workers, UI event handlers), return typed results:
type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E> = Ok<T> | Err<E>;
const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
const err = <E>(error: E): Err<E> => ({ ok: false, error });
async function loadUser(id: string): Promise<Result<User, "not_found" | "db_down">> {
try {
const user = await db.user.findUnique({ where: { id } });
return user ? ok(user) : err("not_found");
} catch {
return err("db_down");
}
}
Libraries: neverthrow, ts-results, or hand-roll as above. Pick one per project. See references/error-handling-result.md.
Zod at boundaries
Every external input parsed with Zod, every internal type inferred from the schema.
import { z } from "zod";
const UserCreate = z.object({
email: z.string().email(),
age: z.number().int().min(0).max(150),
role: z.enum(["admin", "member", "viewer"]),
}).strict();
type UserCreate = z.infer<typeof UserCreate>;
app.post("/users", (req, res) => {
const parsed = UserCreate.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ errors: parsed.error.flatten() });
// parsed.data is fully typed
});
Sources of truth: Zod schema → inferred type. Never define the type separately. See references/zod-boundaries.md.
Migration JS → TS
Gradual, not big-bang. See references/migration-js-to-ts.md for the playbook. Summary:
allowJs: true,checkJs: true— TS checks JS files.- Add JSDoc types as light annotation.
- Rename
.js→.tsfile by file, fix errors. - Turn on
noImplicitAny, thenstrict, then stricter flags one at a time. - Never convert in a single PR more than one team can review.
Build performance
- Project references for monorepos — incremental builds across packages.
isolatedModuleslets bundlers type-check per-file.transpile-onlyin dev (ts-node with swc/esbuild, tsup).- Type-check in CI separately from bundling.
- Skip lib check (
skipLibCheck: true) — trust dep types.
See references/build-performance.md.
Testing
- vitest for Node + browser — fast, Jest-compatible, TypeScript-native.
- Type tests with
expectTypeOf(vitest) ortsdfor library APIs. - Property-based tests with
fast-checkfor pure logic. - Fixtures strongly typed via schemas — reuse Zod schemas for test data generators.
See references/testing-vitest.md.
Anti-patterns
any— signals "I give up on types." Fix, don't paper over.as T(type assertion) — silently lies to the compiler.as unknown as T— the double lie. Always wrong.!non-null assertion — use narrowing or a type guard.Functiontype — use(...args: never[]) => unknownor specific signatures.Object/{}type — useRecord<string, unknown>or a specific shape.enumfor small string sets — use union of string literals.- Class with all static methods — use a namespace-less module.
namespace— use ES modules.- Parameter properties in class constructors where they hurt readability.
- Silent
catch (e) {}— log or re-throw. - Mutating function arguments.
delete obj.keyon typed objects — use spread to build a new object.
See references/anti-patterns.md.
CI gates
tsc --noEmit
eslint . --max-warnings=0
vitest run --coverage --coverage.thresholds.lines=80
Read next
references/typescript-mastery.md— deep type system when you need it.references/typescript-design-patterns.md— GoF patterns in TS.typescript-full-stack— shared types FE↔BE, Zod everywhere.react-development/nextjs-app-router— framework-specific.
References
references/tsconfig-production.mdreferences/effective-ts-items.mdreferences/clean-code-ts.mdreferences/error-handling-result.mdreferences/zod-boundaries.mdreferences/migration-js-to-ts.mdreferences/build-performance.mdreferences/testing-vitest.mdreferences/anti-patterns.md
Decision Rules
| Condition | Action |
|---|---|
| Value crosses a runtime trust boundary | Parse it with a runtime schema |
| Type complexity obscures meaning | Use a named type or simpler API |
| Migration is incremental | Tighten checks by boundary |
Capability Contract
Read and search are required. Editing, dependency updates, builds, and tests require authorisation.
Degraded Mode
Fallback: without execution, provide exact type-check, lint, and test commands. Do not claim runtime safety from static types alone.
Inputs
| Artefact | Required? | Purpose |
|---|---|---|
| TypeScript target, compiler configuration, API contracts, and code scope | yes | Preserve sound types and compatibility |
Outputs
- Produce type-safe code or review findings with compile and test evidence.