satisfies 解决了什么问题:一次类型收窄的实战

类型标注会丢掉字面量信息,as 会跳过检查,satisfies 是唯一「既校验又保留」的写法。用一个配置对象的例子说清楚三者区别。

2 分钟TypeScript类型系统

先看一段我几乎每周都在别人的代码里见到的写法:

type Route = { path: string; cache: boolean };

const routes: Route[] = [
  { path: '/', cache: true },
  { path: '/posts', cache: true },
];

写法没问题,但如果你之后想从 routes 里推导出所有 path 的联合类型,会发现推不出来 —— 它已经被标注成了 Route[],path 退化成 string。

这就是类型标注的代价:它保留了约束,丢掉了精度。

三种写法,三种后果

type Route = { path: string; cache: boolean };

// 1. 标注:检查通过,字面量信息丢失
const a: Route[] = [{ path: '/', cache: true }];
type A = typeof a[number]['path']; // string

// 2. as:字面量保留,但不做检查
const b = [{ path: '/', cache: tru }] as Route[]; // 拼错了也不报错

// 3. satisfies:既检查,又保留
const c = [{ path: '/', cache: true }] satisfies Route[];
type C = typeof c[number]['path']; // "/"

第三种是我现在默认的选择。它做的事情是:用 Route[] 校验这个表达式的形状,如果通过,就保留表达式本身的类型。

编译器的报错也很直接:

Property 'cach' does not exist in type 'Route'.

真实场景:配置表

这是我更常见的用法。假设有一份机制到处理函数的映射,既要保证每个 key 都有对应的函数,又想在调用处拿到精确的参数类型:

interface Handler<T> {
  parse: (raw: string) => T;
  print: (value: T) => string;
}

const handlers = {
  int: {
    parse: (raw) => Number.parseInt(raw, 10),
    print: (value) => String(value),
  },
  json: {
    parse: (raw) => JSON.parse(raw),
    print: (value) => JSON.stringify(value),
  },
} satisfies Record<string, Handler<unknown>>;

注意这里没有写 Record<string, Handler<unknown>> 的标注,所以 handlers.json.parse 的返回类型仍然是 any 而不是 unknown。

用 as 换来的宽松是危险的:as 无法发现你漏掉了 print,而 satisfies 会在构建时告诉你 Property 'print' is missing。

什么时候不该用它

satisfies 不是 as 的替代品,它俩解决的问题不一样。

  • 想约束一个值的形状,且之后要基于这个值做类型推导 → satisfies
  • 想扩大一个值的类型(比如把字面量数组当成 readonly string[] 传给库函数)→ 标注
  • 想绕过检查(从 unknown 恢复、处理第三方类型错误)→ as,但请写注释说明为什么

最后一条我想多说一句:代码里每个 as 都是一次赌注,赌的是「运行时一定是这个形状」。我 review 代码时会把每个 as 当作一个待解释的异常来处理。

类型的价值不在于它多严格,而在于它把错误拦在了哪一步。

← 全部文章