TypeScript is one of those tools that's easy to adopt and hard to
use well. Adding
any
everywhere isn't TypeScript — it's JavaScript with extra steps.
The teams that get real value from it treat the type system as a
design tool, not a compiler requirement to satisfy.
These are the practices we enforce on every TypeScript project — the ones that separate codebases that age well from ones that become type annotation graveyards.
Never use any. Use unknown instead.
any
is a trapdoor — TypeScript stops checking that value everywhere it
flows. Bugs the type system would have caught in development reach
production instead.
unknown
is the honest alternative: "I don't know the type of this yet"
without disabling safety. You're forced to narrow the type before
using it — exactly the discipline you want at trust boundaries
like API responses, user input, and third-party data.
Model your domain in the type system
The most underused TypeScript feature: discriminated unions for domain states.
Instead of a
status: string
field, model every meaningful state explicitly:
type OrderStatus = 'pending' | 'processing' | 'shipped' |
'delivered' | 'cancelled'. TypeScript will exhaustively check every switch statement
against it. Add a new status and forget to handle it somewhere —
the compiler finds it before your users do.
For complex domain objects, discriminated unions go further: each
state variant carries only the fields valid in that state. A
shipped
order has a
trackingNumber. A
pending
order doesn't. The type system encodes that constraint — you can't
accidentally access
trackingNumber
on a pending order without narrowing first.
Validate at the boundary, trust inside
TypeScript's type safety only holds within your codebase. The moment data crosses a boundary — API call, database query, user input — you're dealing with runtime values the compiler cannot verify.
Parse and validate all external data at the point of entry. We use Zod on every project. Define a schema at the boundary, parse incoming data against it, and from that point the data carries a fully typed, verified shape. Anything that fails validation is caught immediately — not discovered as a mysterious null reference three function calls later.
Strict mode is not optional
strict: true
in your tsconfig. Always. It enables
strictNullChecks,
noImplicitAny, and checks that catch the most common runtime bug classes at
compile time.
The objection: "it's too many errors to fix at once." Enable it at
the start of a project when the cost is zero, not 18 months in
when you're staring at 400 type errors. For existing codebases,
enable incrementally — file by file using
// @ts-check
as a migration path.
Generics: the right level of abstraction
Use generics to capture relationships between types, not to avoid writing types. A function that takes an array and returns the first element should be generic. A function that takes a user ID and returns a user record should not — there's one input type and one output type. Reaching for generics where a concrete type works is the most common form of TypeScript over-engineering.
// Good: generic captures the relationship
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const name = getProperty(user, "name"); // type: string
const age = getProperty(user, "age"); // type: number
// Bad: unnecessary generic, adds noise without safety
function identity<T>(value: T): T { return value; }
// Just write: function identity(value: unknown): unknown
For API response wrappers, pagination envelopes, and Result types, generics earn their complexity. For most application code, concrete types are clearer. If you can't read the signature and immediately understand what types flow through it — the abstraction level is wrong.
Use satisfies over type assertions
Type assertions (value as SomeType) tell the compiler to trust you. The compiler does. If you're
wrong, the bug is yours at runtime.
The
satisfies
operator (TypeScript 4.9+) checks that a value conforms to a type
without widening it. You get the safety of a type check without
losing the narrower inferred type. Use it for config objects,
record definitions, and anywhere you'd previously have reached for
an assertion.
Type your errors
JavaScript's
try/catch
gives you an
unknown
error. Most codebases cast it immediately to
any
and hope for the best.
A better pattern: use a Result type — typed success value or typed error — instead of exceptions for expected failure modes. API failures, validation errors, and not-found cases are expected. Model them as data. Your calling code becomes explicit about handling both cases, and you stop losing typed context in catch blocks.
Utility types worth actually knowing
TypeScript's built-in utility types eliminate entire categories of
type duplication. Most engineers know
Partial<T>. These are the ones that change how you design types:
| Utility type | What it does | Where we use it |
|---|---|---|
Pick<T, K>
|
Creates a type with only the specified keys | API response shapes — expose only what a client needs |
Omit<T, K>
|
Creates a type without the specified keys | DTOs from domain models — strip internal fields |
Record<K, V>
|
Object type with keys K and values V | Lookup tables, config maps, indexed collections |
ReturnType<T>
|
Extracts the return type of a function | Type factory output without repeating the type |
NonNullable<T>
|
Removes null and undefined from T | Post-validation types — data confirmed as present |
Awaited<T>
|
Unwraps the resolved type of a Promise | Type async results without verbose Promise nesting |
The pattern: define your canonical types once (database schema, API responses, domain models), then derive all related types using utilities. Changing the canonical type propagates everywhere automatically.
ESLint rules that enforce type safety
typescript-eslint
extends ESLint with rules that catch type-level problems beyond
what the compiler checks:
🚫 @typescript-eslint/no-explicit-any
Bans explicit any annotations. The compiler won't stop you — this rule will. Allows narrowly scoped eslint-disable overrides for genuine escape hatches.
✅ @typescript-eslint/no-floating-promises
Catches async functions whose return values are not awaited. Unhandled promises are a common source of silent failures — errors swallowed without being caught or logged.
🔍 @typescript-eslint/strict-boolean-expressions
Disallows implicit boolean coercion of non-boolean types. Prevents the bug class where null, undefined, 0, and empty string are accidentally treated as equivalent in conditionals.
⚠️ @typescript-eslint/switch-exhaustiveness-check
Errors when a switch doesn't handle all members of a union type. Add a new variant, get a compile-time error at every switch that needs updating.
📋 @typescript-eslint/consistent-type-imports
Enforces import type for type-only imports. Eliminates circular dependency issues and makes bundle analysis cleaner.
Project structure and path aliases
Import paths are a proxy for architecture quality. A codebase full
of
../../../../shared/utils/format
is one where moving files is painful and module boundaries are
unclear.
Configure path aliases in
tsconfig.json:
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/components/*": ["src/components/*"],
"@/lib/*": ["src/lib/*"],
"@/types/*": ["src/types/*"],
"@/server/*": ["src/server/*"]
}
}
Now imports read as
import { formatCurrency } from "@/lib/format"
regardless of where the importing file lives. The alias signals
you're crossing a module boundary — within the same module,
relative imports remain correct and clearer.
The principle underneath all of this
Good TypeScript makes impossible states unrepresentable. If a combination of values can't exist in a valid system, the type system should prevent it from being constructed — not just document that it shouldn't happen.
That's a higher bar than 'the compiler doesn't complain.' It takes deliberate design. But codebases that meet it are dramatically easier to refactor, extend, and hand off — because the type system carries context that would otherwise live only in the original author's head.

