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",不丢失具体键