TypeScript Expert
Overview
Idiomatic TypeScript is about making illegal states unrepresentable and letting inference do the work. The compiler is a design tool, not a formality. These are the judgment calls a linter can't make for you.
Quick Reference
| Goal | Do | Avoid |
|---|---|---|
| Closed set of values | union literals 'a' | 'b' or as const object |
enum |
| Validate a literal against a type | satisfies T |
: T (widens) or as T (lies) |
| Untrusted input | unknown + narrow |
any |
| Variant data | discriminated union (kind tag) |
optional flags / many ?: |
| Distinct primitives (ids, tokens) | branded type | bare string/number |
| Object shape | interface (extensible, faster errors) |
type for plain objects |
| Union/mapped/conditional | type |
interface (can't express it) |
| Import only types | import type {…} |
value import (emits a phantom runtime import) |
| Exhaustive switch | default: assertNever(x) |
no default |
| Block inference from an arg | NoInfer<T> on the slave position |
letting a default/fallback widen T |
| Infer literals at the signature | <const T> type param |
forcing callers to write as const |
| Scoped cleanup (files, locks, spans) | using / await using |
manual try/finally |
Core Patterns
Discriminated unions over flag soup — illegal combinations stop compiling:
// ❌ allows { loading: true, data: [...] } — a contradiction
type State = { loading: boolean; error?: string; data?: User[] }
// ✅ each variant carries exactly its own data
type State =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; data: User[] }
Runnable:
examples/discriminated-union.ts
Exhaustiveness — new variants become compile errors, not silent bugs: put default: return assertNever(state) on a switch over the discriminant; adding a variant turns "forgot a case" into a type error.
Runnable:
examples/exhaustiveness.ts
assertNever, the Brand<T,B> helper, and Result/ok/err live in types.ts — copy it in.
satisfies — check without widening, keep the literal type:
const config = { port: 3000, host: 'localhost' } satisfies Record<string, string | number>
config.port // still `number`, not `string | number`
Runnable:
examples/satisfies.ts
NoInfer<T> — pin the generic from one argument, only check the other:
function on<E extends string>(events: E[], initial: NoInfer<E>) { /* … */ }
on(['a', 'b'], 'c') // ❌ 'c' can't widen E; must be 'a' | 'b'
Runnable:
examples/no-infer.ts
using — deterministic cleanup, LIFO at scope end, even on throw/early-return:
{
using span = tracer.start('work') // span[Symbol.dispose]() runs automatically
using lock = await mutex.acquire()
} // disposed lock-then-span here — no try/finally
Use await using for Symbol.asyncDispose. Needs lib: ["ESNext.Disposable"].
Runnable:
examples/using-disposable.ts
Generics: constrain and infer, don't over-parameterize. A type parameter used once is usually wrong — it should appear in ≥2 positions (input→output) to relate them. Constrain with extends so the body can use the shape.
Runnable:
examples/generics-relate.ts
Common Mistakes
asto silence errors — an assertion is an unchecked claim. If you must, the only safe widening cast is tounknownfirst; otherwise narrow with a type guard (x is T).enum— emits runtime code, has nominal/structural surprises, doesn't tree-shake. Useas constobjects or union literals.anyanywhere — it disables checking transitively. Useunknownat boundaries and narrow.- Over-typing — don't restate what inference already knows (
const n: number = 5). Annotate function parameters and public return types; let locals infer. Function,object,{}— too loose. Use precise signatures /Record<K,V>/unknown.- Non-null
!to chase undefined — fix the type or narrow;!hides the real nullability. Promisewithoutawait/return — enableno-floating-promises; an unhandled rejection is silent.
When NOT to over-engineer
Branded types, deep conditional types, and template-literal type gymnastics have a real readability cost. Reach for them when they prevent a class of real bugs — not to show the type system can do it. If a type is harder to read than the bug it prevents, simplify.
tsconfig baseline
strict: true is the floor. Add noUncheckedIndexedAccess (arr[i] is T | undefined), exactOptionalPropertyTypes (x?: can't be explicitly set to undefined), and verbatimModuleSyntax (forces import type, no phantom runtime imports). These catch real bugs default strict misses. A ready-to-extend tsconfig.base.json is in this dir.
Versions:
satisfies(4.9), const type params (5.0),using/await using(5.2),NoInfer(5.4). All stable on the current 5.x/6.x line.
Sources
- TypeScript Handbook · Utility Types (
NoInfer) - tsconfig reference — flag semantics
- TS 4.9 release notes —
satisfies - TS 5.0 release notes — const type parameters
- TS 5.2 release notes —
using/ explicit resource management