看形状不看出身: 字段对得上就能赋值 — 灵活的另一面是"结构相同但语义不同"的暗门
TS 不问"你是谁生的", 只问"你长什么样" — 结构化类型(structural typing)。一个对象只要至少具备目标类型要求的每个字段, 就能赋值过去, 多出来的字段无所谓。所以你写一个临时对象、一个 mock、一个第三方返回值, 只要形状对得上, 就"是"那个接口 — 没有任何 implements 声明仪式。
代价与出口都要清楚: 结构相同≠语义相同, UserId 和 OrderId 都是 string 形状时互串是经典事故, 用 brand 字段把"名义性"补回来; 而 any 是把整个检查系统关掉的逃生舱, unknown 才是诚实的版本 — 什么都进得来, 但你必须先收窄给出证据才能用。外部数据永远走 unknown + 校验。
interface A { x: number } interface B { x: number } const a: A = { x: 1 }; const b: B = a; // ✓ 结构相同即兼容
const big = { x: 1, y: 2, z: 3 }; const small: { x: number } = big; // ✓ 宽→窄赋值
const p: { x: number } = { x: 1, y: 2 }; // ✗ 报错 const t = { x: 1, y: 2 }; const p2: { x: number } = t; // ✓
type Opts = { a?: number; b?: number }; const o: Opts = { c: 1 }; // ✗ 无任何交集属性
interface Bag { [k: string]: unknown; size: number } // 具名键的类型必须兼容索引签名
let x: any = json; x.foo.bar(); // 编译全绿, 运行时可能爆 const y: number = x; // ✓(坏!) any 污染扩散
const v: unknown = JSON.parse(s); v.foo; // ✗ 逼你先验证 if (typeof v === 'object' && v) /* ... */
function fail(): never { throw new Error(); } type Exh = Circle | Rect; const _e: never = shape; // 穷尽时 OK, 漏分支报错
interface Cmp { cmp(o: Animal): number } const c: Cmp = { cmp(o: Dog) { return 0; } }; // 方法简写形式下允许(双变) — 是历史包袱
type EUR = Brand<number, 'EUR'>; type USD = Brand<number, 'USD'>; // convert(eur2usd(e)) 编译期锁死币种
interface W { w: 1 } interface W { v: 2 } // 声明合并 → { w, v } type U = A | B; // type 独有: 联合
class A { #id = 1 } class B { #id = 1 } const a: A = new B(); // ✗ 私有字段不同
接口返回的 DTO 与领域实体字段高度重叠, 用结构兼容直接桥接, 差异字段显式映射:
interface UserDTO { id: string; name: string; vip: 0 | 1 } interface User { id: string; name: string; vip: boolean } function toUser(d: UserDTO): User { return { ...d, vip: d.vip === 1 }; // 差异字段显式转 } // 相同部分靠结构兼容免写, 差异部分一目了然
批量导出把 orderId 传进 userQuery, string 形状相同静默跑错。brand 化两个 ID 类型:
type Brand<T, B> = T & { __brand: B }; const mkUserId = (s: string) => s as Brand<string, 'UserId'>; const mkOrderId = (s: string) => s as Brand<string, 'OrderId'>; lookupUser(mkOrderId('o1')); // ✗ 编译期拦下 // 出口函数是唯一入口, 校验格式(前缀/长度)也挂在这里
fetch.json() 默认 any(lib dom)。项目级收口: 一律先 unknown 再校验:
async function getJson(url: string): Promise<unknown> { const r = await fetch(url); return r.json(); // unknown 出口 } const data = await getJson('/api/u'); const user = UserSchema.parse(data); // 校验后才进业务
宿主只声明"我需要什么", 插件多出来的能力随意 — 结构化让生态自然生长:
interface Plugin { name: string; setup(ctx: { logger: Logger }): void | Promise<void>; } // 插件自己带缓存/配置/生命周期, 宿主不关心: const p: Plugin = { name: 'cache', setup(ctx) { ctx.logger.info('cache ready'); }, };
回调参数声明为"最小需求", 调用方传更强类型的函数也兼容(参数逆变):
interface Visitor { visit(item: { id: number }): void } // 调用方函数能处理更多字段, 一样能当 Visitor 用: const v: Visitor = (item: { id: number; name?: string }) => {}; // 宽进: 需求越少, 兼容面越大 — API 设计的黄金法则
接口 20 个方法, 单测只用 2 个。结构化允许"部分实现 + as 断言缺口":
const store: Pick<Store, 'get' | 'set'> = { get: (k) => fixtures[k], set: (k, v) => { recorded.push([k, v]); }, }; // 用 Pick 而不是 as Store: 缺口显式, 不是"全闭嘴"
固定键要精确类型, 动态键要兜底 — 拆两个视图而不是混一个接口:
interface RawConfig { [k: string]: unknown } interface KnownConfig { port: number; host: string } function load(raw: RawConfig): KnownConfig { return ConfigSchema.parse(raw); // 已知键强类型 } // 未知键走 raw 的动态视图, 已知键走强类型视图
五年老库 300 处 any。路线: @typescript-eslint no-explicit-any 警告 → 逐个改 unknown+收窄 → 转 error:
// 1. 显式 any 全部改 unknown, 编译错误清单=待办清单 // 2. 每处补收窄或校验: function old(o: unknown) { if (typeofRecord(o)) return o.name; // 收窄后用 throw new TypeError('unexpected'); } // 3. CI 设 error 防回流
同一形状手写两份(运行时校验 + 编译期类型)必漂移。schema 单源:
const OrderSchema = z.object({ id: z.string(), cents: z.number().int(), }); type Order = z.infer<typeof OrderSchema>; // 编译期类型 const order = OrderSchema.parse(payload); // 运行时验证 // 一份定义两处生效, 结构漂移在编译与运行时都被抓
Options 全可选, 手滑把另一个对象整个传进来, 没有任何交集字段。弱类型检测当场拦下:
type RetryOpts = { times?: number; backoff?: boolean }; retry(fn, { timeout: 3000 }); // ✗ Object literal may only specify known properties // 弱类型检测: 与目标没有任何交集字段就报错
const t = { x: 1, typo: 2 }; const p: P = t; // ✓ 通过 — typo 没人管
const x: any = f(); const y: number = x.anyProp; // 全绿但全是雷
// 错: v.foo() // Object is of type 'unknown' // 对: if (isFoo(v)) v.foo();
type Cny = Brand<number, 'CNY'>; type Usd = Brand<number, 'USD'>;
?? 或判空。 // { a?: number } 兼容 { } — a 是 undefined opts.a.toFixed(); // ✗ 严格模式必报 (opts.a ?? 0).toFixed();
// 错: interface C { [k: string]: number; port: number } // prot 不报错
// {a?:1,b?:1} 收 {a:9,c:9} ✓ — c 没人管interface H { handler: (e: Base) => void } // 属性形式, 严格逆变
// 两个包都 interface Window { x } → 静默合并function sum(xs: readonly number[]): number // 宽进
const t: [number, number] = [1, 2]; const a: number[] = t; // ✓ 长度信息丢了
let x: {} = 42; // ✓ 数字也行, 反直觉 // 对: let x: Record<string, unknown>;
// as User 后 u.name 运行时可能 undefinedfunction f(xs: readonly Animal[]) // Dog[] 可传入 ✓
const p = { port: 8080 } satisfies Cfg; // port: 8080 保留
class Money { #c: number; } // Money 与 {c} 不兼容
on('click', (e: unknown) => { /* 自己收窄 */ })
// 每个 as 都写一行"为什么安全", 无法解释就重构// 全可选类型不适合"必须正确"的输入, 用 required// 对: openapi-typescript 生成类型, 契约在文档层