any Is a Contagious Hole
The semantics of `any` are not "I do not know" but "turn off type checking here". Once a function returns `any`, inference degrades along the entire call chain: downstream variables become `any`, arguments stop being validated, renames no longer resolve, and refactoring turns fully manual.
If `any` exists only to work around a library missing type declarations, the right move is to build a narrow interface that fences the `any` at the boundary rather than letting it into business logic. Once inside, removal cost becomes exponential, because nobody can enumerate all affected call sites.
The usual degradation path is: one `any`, then `any[]` and `Promise<any>`, and finally an entire data flow with no protection. A quick test: if replacing `any` with `unknown` immediately explodes into a hundred errors, that chain lost type safety long ago.
// 反例:把 unknown 丢进业务层
function parse(input: string): any {
return JSON.parse(input)
}
// 正例:边界处收窄,内部保持类型安全
type Config = { port: number; host: string }
function parseConfig(input: string): Config {
const raw: unknown = JSON.parse(input)
if (typeof raw !== "object" || raw === null) throw new TypeError("config must be an object")
const port = (raw as Record<string, unknown>).port
const host = (raw as Record<string, unknown>).host
if (typeof port !== "number" || typeof host !== "string") {
throw new TypeError("config.port must be number and config.host must be string")
}
return { port, host }
}Discriminated Unions: Modeling the State Machine in the Type
When an object has several shapes (loading, success, failure), optional fields explode combinatorially: what if `loading` and `error` are both true? A discriminated union gives each branch a literal tag, so the compiler verifies that branches are exhaustive and that fields are not read in the wrong branch.
The biggest payoff is exhaustiveness checking on `switch`. Once you enumerate every tag of the union, omitting one branch makes a `never` assertion fail to compile. Adding a new state then produces a compile error listing every switch that needs updating, instead of a runtime surprise.
Put the `never` assertion in the default branch as `const _exhaustive: never = state`, not inside each case. Written per-case, an assertion narrows to `{}` and quietly stops protecting anything.
type RequestState =
| { status: "idle" }
| { status: "loading"; startedAt: number }
| { status: "success"; data: string }
| { status: "error"; message: string }
function render(state: RequestState): string {
switch (state.status) {
case "idle":
return "尚未开始"
case "loading":
return `已耗时 ${Date.now() - state.startedAt}ms`
case "success":
return state.data
case "error":
return `失败:${state.message}`
default: {
const _exhaustive: never = state
return _exhaustive
}
}
}
// 新增 { status: "cancelled" } 后,所有遗漏分支的 switch 都会编译报错Generic Constraints: Making an API State Its Real Requirements
A bare `<T>` is `<T>` plus a hidden `any`: property access inside the function compiles fine, but callers may pass a primitive. Writing `T extends Record<string, unknown>` is only the start — the real payoff is tying the constraint to the return type so T's capabilities propagate outward.
Three patterns recur in practice: constraint plus default (a usable starting point for callers), constraint plus index signature (arbitrary keys but controlled value types), and conditional types mapping input to output (like `ReturnType` or a framework `MaybePromise<T>`). The third is the most abused because it easily destroys compile times.
The value of constraints is early failure. A well-constrained generic rejects bad calls at compile time, stopping errors at the boundary; an unconstrained one defers them to runtime, sometimes to production.
type Mapper<T extends Record<string, unknown>> = {
keys: () => Array<keyof T>
get<K extends keyof T>(key: K): T[K] | undefined
set<K extends keyof T>(key: K, value: T[K]): Mapper<T>
}
function createMapper<T extends Record<string, unknown> = { id: string }>(initial: T): Mapper<T> {
const store = new Map<keyof T, T[keyof T]>(Object.entries(initial) as Array<[keyof T, T[keyof T]]>)
return {
keys: () => Array.from(store.keys()),
get: (key) => store.get(key),
set: (key, value) => {
store.set(key, value)
return createMapper(Object.fromEntries(store) as T)
},
}
}
// 传入 number 会立刻报错:Mapper<number> 不满足 Record<string, unknown>Why Narrowing Fails: Five Common Breakpoints
First: narrowing an optional property with `if (obj.key)` while forgetting the `undefined` branch. Second: using `typeof` or a bare truthiness check on an array, since TypeScript auto-narrows only a limited set of forms. Third and most subtle: TS does not carry a branch across statements for an already-declared union variable, so an explicit assertion or a fresh check is required.
Fourth: `let` versus `const`. TypeScript assumes a `let` holding a union may be reassigned between function calls, so it refuses to keep the narrowing; switching to `const`, or binding a new `const` inside the branch, lets narrowing through.
Fifth: mixing imported `type` and `interface` so structural identity breaks, and `@ts-expect-error` silently burying real errors. Use a fixed debugging order: confirm the binding is `const`, confirm the discriminant is a literal type (not `string`), and only then reach for `as`.
type Box = { kind: "a"; a: number } | { kind: "b"; b: string }
// 失败写法:kind 声明成 string,无法收窄
function bad(box: { kind: string }) {
if (box.kind === "a") {
// 这里 box.a 不存在,因为 kind 不是字面量类型
}
}
// 正确写法:用 as 收窄到联合类型
function good(box: Box) {
if (box.kind === "a") {
return box.a + 1
}
return box.b.length
}
// 排查三连:const 了吗?kind 是字面量吗?分支赋值过吗?Wiring Type Constraints into the Engineering Loop
Type design only survives contact with reality through three hard settings: full `strict: true`, `noUncheckedIndexedAccess: true` so array and index access admits `undefined`, and making `@typescript-eslint/no-explicit-any` and `ban-ts-comment` errors so neither `any` nor `@ts-ignore` slips in quietly.
The second key is controlling what crosses the public API boundary. Internal code may use assertions freely, but any type reused across packages should be an explicitly declared interface. Never export `typeof response` from a backend payload — internal field changes then break every downstream consumer with no compile-time signal.
Finally use `satisfies` for bidirectional checking: the object must satisfy the target type while its inferred literal types are preserved. For route tables and event maps that need both correctness and concrete keys, `satisfies` beats `as const` because it does not lie on your behalf.
// tsconfig.json 关键项
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true
}
}
// 路由表:satisfies 同时保证键完整与字面量推断
const routes = {
home: { title: "首页", auth: false },
admin: { title: "管理", auth: true },
} satisfies Record<string, { title: string; auth: boolean }>
type RouteKey = keyof typeof routes // "home" | "admin",不丢失具体键