TypeScript · 结构化类型与 any/unknown

看形状不看出身: 字段对得上就能赋值 — 灵活的另一面是"结构相同但语义不同"的暗门

结构兼容: 只看形状 Pointed x: number y: number 接口要求的形状 实际对象 x: 1, y: 2 label: 'A' tag: 7 多出来的字段没问题 能赋值 = 右边至少有左边要的每个字段 没有"必须实现某接口"的声明仪式 mock/测试对象天然满足接口 — 这是它的甜 UserId 结构同 OrderId 时互串 — 这是它的苦 多余属性检查: 只盯字面量 const p: P = { x: 1, y: 2, zz: 0 }; // ✗ 对象字面量直接赋值: // 多余属性检查生效 → zz 报错 const tmp = { x: 1, y: 2, zz: 0 }; const p2: P = tmp; // ✓ 变量赋值不检查 "新鲜度"规则: 只针对刚写出来的字面量 目的: 抓"手滑多打/拼错的属性名", 不是安全机制 any vs unknown any: 双向关闸 什么都赋给它 → 编译器闭嘴 它赋给谁/调什么 → 编译器闭嘴 类型系统在这里失效, 错误推迟到运行时 unknown: 只进不出 什么都赋给它 ✓ 用之前必须收窄/断言 ✓ 外部数据的正确入口类型 // 结构相同≠语义相同: 用 brand(品牌字段)造"名义类型" type Brand<T, B extends string> = T & { __brand: B }; type UserId = Brand<string, 'UserId'>; type OrderId = Brand<string, 'OrderId'>; declare function findUser(id: UserId): User; const oid = 'ord_1' as OrderId; findUser(oid); // ✗ 编译错: OrderId 不能赋给 UserId // 明确字符串进 brand 必须过"出口函数"(如 mkOrderId), 混用从运行时 bug 变编译错

形状即类型

  • • 没有 implements 的强制仪式
  • • 右边字段 ⊇ 左边要求 即兼容
  • • mock/DTO/测试替身天然可用
  • • 接口只是"形状描述", 不是出身证明

字面量的额外检查

  • • 新鲜字面量多字段 → 报错
  • • 经变量绕行 → 不检查
  • • 目的: 抓拼错/多打的属性
  • • 全可选"弱类型"也有专项检查

any 是逃生舱不是家

  • • any 双向关闭检查, 错误后移
  • • unknown 只进不出, 强制收窄
  • • 外部输入一律 unknown/校验器
  • • 语义隔离用 brand 名义化

💡 一句话理解

TS 不问"你是谁生的", 只问"你长什么样" — 结构化类型(structural typing)。一个对象只要至少具备目标类型要求的每个字段, 就能赋值过去, 多出来的字段无所谓。所以你写一个临时对象、一个 mock、一个第三方返回值, 只要形状对得上, 就"是"那个接口 — 没有任何 implements 声明仪式。

代价与出口都要清楚: 结构相同≠语义相同, UserId 和 OrderId 都是 string 形状时互串是经典事故, 用 brand 字段把"名义性"补回来; 而 any 是把整个检查系统关掉的逃生舱, unknown 才是诚实的版本 — 什么都进得来, 但你必须先收窄给出证据才能用。外部数据永远走 unknown + 校验。

🧠 必知必会 必考 & 必会

结构化 vs 名义
TS/Go 是结构化(看形状); Java/C# 是名义(看类名声明); 同一形状的两个 interface 互通。
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 };   // ✗ 无任何交集属性
索引签名
[k: string]: T 表示"任意字符串键都是 T"; 会让具体键检查变宽, 慎与具名键混用。
interface Bag { [k: string]: unknown; size: number }
// 具名键的类型必须兼容索引签名
any
双向免检通道: 进出都放弃检查, 还会传染(表达式沾上 any 就 any)。
let x: any = json;
x.foo.bar();      // 编译全绿, 运行时可能爆
const y: number = x;  // ✓(坏!) any 污染扩散
unknown
类型安全的顶类型: 什么都能收, 只能赋给 unknown/自身, 使用前必须收窄。
const v: unknown = JSON.parse(s);
v.foo;             // ✗ 逼你先验证
if (typeof v === 'object' && v) /* ... */
never 底类型
没有任何值; 可赋给一切; 穷尽检查/不可能分支的"哨兵"。
function fail(): never { throw new Error(); }
type Exh = Circle | Rect;
const _e: never = shape;  // 穷尽时 OK, 漏分支报错
方法双变
方法参数历史上双向兼容(不安全), strictFunctionTypes 只管函数属性; 回调参数尽量写宽。
interface Cmp { cmp(o: Animal): number }
const c: Cmp = { cmp(o: Dog) { return 0; } };
// 方法简写形式下允许(双变) — 是历史包袱
brand 名义化
给形状挂唯一符号字段, 让"同形状不同义"的类型互不兼容。
type EUR = Brand<number, 'EUR'>;
type USD = Brand<number, 'USD'>;
// convert(eur2usd(e)) 编译期锁死币种
interface vs type
能力几乎等价: interface 可声明合并、extends; type 可联合/映射/条件; 团队统一即可。
interface W { w: 1 }
interface W { v: 2 }    // 声明合并 → { w, v }
type U = A | B;          // type 独有: 联合
class 的名义角落
class 带 #私有字段时呈名义行为: 私有字段不同则结构再像也不兼容。
class A { #id = 1 }
class B { #id = 1 }
const a: A = new B();   // ✗ 私有字段不同

🏭 生产实战 real world

场景 1 · DTO 与领域模型分层: 结构兼容做桥

接口返回的 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 };   // 差异字段显式转
}
// 相同部分靠结构兼容免写, 差异部分一目了然

场景 2 · UserId/OrderId 串号: brand 修复线上事故

批量导出把 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'));   // ✗ 编译期拦下
// 出口函数是唯一入口, 校验格式(前缀/长度)也挂在这里

场景 3 · 第三方响应 unknown 化: JSON.parse 不再裸奔

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);   // 校验后才进业务

场景 4 · 插件契约: 最小接口宽进

宿主只声明"我需要什么", 插件多出来的能力随意 — 结构化让生态自然生长:

interface Plugin {
  name: string;
  setup(ctx: { logger: Logger }): void | Promise<void>;
}
// 插件自己带缓存/配置/生命周期, 宿主不关心:
const p: Plugin = {
  name: 'cache',
  setup(ctx) { ctx.logger.info('cache ready'); },
};

场景 5 · 函数参数宽进严出: 兼容性设计

回调参数声明为"最小需求", 调用方传更强类型的函数也兼容(参数逆变):

interface Visitor { visit(item: { id: number }): void }
// 调用方函数能处理更多字段, 一样能当 Visitor 用:
const v: Visitor = (item: { id: number; name?: string }) => {};
// 宽进: 需求越少, 兼容面越大 — API 设计的黄金法则

场景 6 · 测试 mock 零仪式: 只实现被测路径

接口 20 个方法, 单测只用 2 个。结构化允许"部分实现 + as 断言缺口":

const store: Pick<Store, 'get' | 'set'> = {
  get: (k) => fixtures[k],
  set: (k, v) => { recorded.push([k, v]); },
};
// 用 Pick 而不是 as Store: 缺口显式, 不是"全闭嘴"

场景 7 · 索引签名 + 具名键: 动态配置表

固定键要精确类型, 动态键要兜底 — 拆两个视图而不是混一个接口:

interface RawConfig { [k: string]: unknown }
interface KnownConfig { port: number; host: string }
function load(raw: RawConfig): KnownConfig {
  return ConfigSchema.parse(raw);   // 已知键强类型
}
// 未知键走 raw 的动态视图, 已知键走强类型视图

场景 8 · any 渐进清零: lint + unknown 双闸

五年老库 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 防回流

场景 9 · zod 输出 = 手写 interface 替代

同一形状手写两份(运行时校验 + 编译期类型)必漂移。schema 单源:

const OrderSchema = z.object({
  id: z.string(), cents: z.number().int(),
});
type Order = z.infer<typeof OrderSchema>;  // 编译期类型
const order = OrderSchema.parse(payload);  // 运行时验证
// 一份定义两处生效, 结构漂移在编译与运行时都被抓

场景 10 · 弱类型误传: 全可选配置的拦截

Options 全可选, 手滑把另一个对象整个传进来, 没有任何交集字段。弱类型检测当场拦下:

type RetryOpts = { times?: number; backoff?: boolean };
retry(fn, { timeout: 3000 });
// ✗ Object literal may only specify known properties
// 弱类型检测: 与目标没有任何交集字段就报错

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 多余检查被变量绕过 — 新鲜度检查只盯字面量, 变量中转后拼写错误放行. 正解: 关键入口仍配 zod/白名单校验。
const t = { x: 1, typo: 2 };
const p: P = t;   // ✓ 通过 — typo 没人管
坑 2 · any 传染 — 表达式沾 any 即 any, 检查静默失效. 正解: lint 禁显式 any, 边界改 unknown。
const x: any = f();
const y: number = x.anyProp;  // 全绿但全是雷
坑 3 · unknown 直接用 — 方法/属性访问编译错是特性. 正解: 收窄(typeof/in/守卫)后再用。
// 错: v.foo()        // Object is of type 'unknown'
// 对: if (isFoo(v)) v.foo();
坑 4 · 同形不同义互串 — 结构相同让 ID/币种/单位混用无声通过. 正解: brand 名义化 + 出口函数。
type Cny = Brand<number, 'CNY'>; type Usd = Brand<number, 'USD'>;
坑 5 · 可选属性仍要防 undefined — 结构兼容不含"值真的在". 正解: 默认值 ?? 或判空。
// { a?: number } 兼容 { } — a 是 undefined
opts.a.toFixed();      // ✗ 严格模式必报
(opts.a ?? 0).toFixed();
坑 6 · 索引签名放闸 — [k: string]: unknown 让任意键读写合法, typo 全放行. 正解: 已知键走具名类型, 动态键走 Map。
// 错: interface C { [k: string]: number; port: number } // prot 不报错
坑 7 · 全可选弱类型漏检 — 传完全无关对象本想报错, 但有交集就过. 正解: 关键配置用 required + zod。
// {a?:1,b?:1} 收 {a:9,c:9} ✓ — c 没人管
坑 8 · 方法参数双变 — 方法简写参数不逆变, 把超类当参数的函数也能兼容. 正解: 属性形式声明函数类型启用严格检查。
interface H { handler: (e: Base) => void }  // 属性形式, 严格逆变
坑 9 · interface 合并惊喜 — 同名 interface 自动合并, 第三方同名即碰撞. 正解: 库导出用 type 或命名空间隔离。
// 两个包都 interface Window { x } → 静默合并
坑 10 · readonly 数组互赋 — 可变数组可赋给只读, 反向不行; 混用引发"只读数组怎么 push"困惑. 正解: API 入参收 readonly, 返回给可变。
function sum(xs: readonly number[]): number  // 宽进
坑 11 · 元组与数组互赋 — 数组可赋给元组?反了: 元组可赋给数组, 长度信息丢失. 正解: 元组场景标注 tuple 类型。
const t: [number, number] = [1, 2];
const a: number[] = t;   // ✓ 长度信息丢了
坑 12 · 空接口 {} 万物兼容 — {} 表示"任意非空值", 不是"空对象". 正解: 用 object / unknown / Record 表达意图。
let x: {} = 42;   // ✓ 数字也行, 反直觉
// 对: let x: Record<string, unknown>;
坑 13 · 结构兼容不查运行时 — 类型对了数据仍可能缺, as 结构断言不是校验. 正解: 边界 zod.parse。
// as User 后 u.name 运行时可能 undefined
坑 14 · 泛型不变性 — Array<Dog> 不是 Array<Animal>(可写引发不安全), 会报错. 正解: 只读位置用 readonly T[] 或 T extends 传型。
function f(xs: readonly Animal[])   // Dog[] 可传入 ✓
坑 15 · satisfies 与 as 混淆 — as 丢字面量推断, satisfies 保留; 用错丢精度. 正解: 校验常量表用 satisfies。
const p = { port: 8080 } satisfies Cfg;  // port: 8080 保留
坑 16 · class 私有字段名义化 — #字段让两个同形状类不兼容(可能正是你要的). 正解: 需要鸭子兼容就别用 #, 需要名义就大胆用。
class Money { #c: number; }  // Money 与 {c} 不兼容
坑 17 · 函数参数 bivariance 老 API — 事件回调参数被"放宽", 传错对象编译不报. 正解: strictFunctionTypes 开启 + 属性形式函数。
on('click', (e: unknown) => { /* 自己收窄 */ })
坑 18 · any 类型断言伪装 — json as User 系列本质 any 化. 正解: 断言只留边界+注释, 内部走收窄/校验。
// 每个 as 都写一行"为什么安全", 无法解释就重构
坑 19 · 弱类型拼错值类型 — 全 boolean 配置传 { tiemout: true } 交集检查过但字段错. 正解: 关键配置 required + schema。
// 全可选类型不适合"必须正确"的输入, 用 required
坑 20 · 把结构化当无契约 — "形状对就行"助长隐式耦合, 字段一改全靠编译器兜底. 正解: 跨团队契约仍走 schema/OpenAPI, 类型由其生成。
// 对: openapi-typescript 生成类型, 契约在文档层