satisfies 检查一个表达式能否赋给目标类型,但不会直接把表达式的结果类型替换成目标类型。
它只在编译时工作,既不会验证外部数据,也不保证每个属性都保留字面量类型。
用有限键集合描述静态表格,用运行时解析器守住数据边界;确实需要只读字面量时再组合 as const。
是什么,为什么存在
satisfies 操作符(satisfies operator) 检查左侧表达式的类型能否赋给右侧目标类型。检查通过后,变量仍暴露表达式得到的具体类型,而不是统一暴露目标类型。它适合配置对象、路由表、命令注册表等“既要校验整体结构,又要继续使用每个条目的具体信息”的声明。
类型注解(type annotation) 回答“这个变量对外应是什么类型”。它会把声明绑定到写出的契约,因此之后的读取和赋值都按该契约处理。satisfies 回答的问题更窄:“这个表达式是否兼容该契约”。
类型断言(type assertion) 则要求检查器接受开发者的判断。断言可以跳过本来会失败的兼容性检查,而 satisfies 不能把错误的值强行变成正确类型。需要证明一个源码内常量的结构时,优先使用检查;需要处理网络、文件或环境变量时,两者都不能替代运行时验证。
这个差别最常出现在异构对象中。一个调色板的值可能是字符串,也可能是 RGB 元组;若把整个变量注解成 Record<string, string | RGB>,读取任何条目都只得到联合类型。satisfies 可以检查所有条目,同时让已知字符串条目继续使用字符串方法,让数组条目继续按元组处理。
工作原理
value satisfies Target 是一个表达式。编译器先用 Target 为左侧表达式提供上下文,再检查左侧得到的类型是否可赋给 Target。最终表达式的类型来自左侧推断结果,而不是简单替换为 Target。
因此,“satisfies 完全不影响推断”也不够准确。目标类型会参与对象字面量、数组字面量和回调参数的上下文类型推断。例如,目标中的 "GET" | "POST" 能让某个 method 保持为 "GET",而目标中的普通 string 通常仍让可变对象属性推断为 string。
一次检查可以拆成四步:
- 读取目标类型,建立属性、索引签名和回调的上下文。
- 在该上下文中推断左侧表达式的类型。
- 按 TypeScript 的结构类型与可赋值性规则检查兼容关系。
- 保留推断出的表达式类型,并在生成 JavaScript 时移除
satisfies Target。
结构类型(structural typing) 意味着兼容性主要取决于成员形状,而不是声明名称。必填属性必须存在,属性值必须兼容;直接对象字面量还会触发多余属性检查。若目标含有 Record<string, Entry> 这样的开放索引签名,任意字符串键都合法,因此它不能发现键名拼写错误。
satisfies 不会创建属性、冻结对象、转换值或发出运行时代码。它也不会让 JSON.parse() 产生的 any 变安全,因为 any 本来就能绕过大多数静态检查。把外部值先保留为 unknown,经过真实的运行时检查后再赋予领域类型。
示例
下面四个示例逐步构造一个静态注册表。它们分别展示具体成员推断、异构值、as const 组合,以及静态检查与运行时验证的边界。
校验路由注册表
类型注解适合固定公开契约,但会让每次属性读取都只看到该契约。satisfies 仍检查精确键集合,并保留 health.method 的具体 "GET" 类型。
type Method = "GET" | "POST";
type Route = { path: string; method: Method };
const annotated: Record<string, Route> = {
health: { path: "/health", method: "GET" },
};
const routes = {
health: { path: "/health", method: "GET" },
createUser: { path: "/users", method: "POST" },
} satisfies Record<"health" | "createUser", Route>;
function acceptGet(method: "GET"): string {
return method;
}
console.log(acceptGet(routes.health.method));
console.log(Object.keys(routes).join(","));
console.log(annotated.health.method);GET
health,createUser
GETacceptGet(routes.health.method) 能通过检查,因为目标联合为该属性提供了上下文,而结果类型仍保留已选择的联合成员。annotated.health.method 在运行时也是 GET,但它的静态类型是完整的 Method,所以不能直接传给只接受 "GET" 的函数。
保留异构条目的能力
目标类型允许字符串或 RGB 元组。检查器既会拒绝长度错误的数组,也会让已知条目保留各自可用的操作。
type RGB = readonly [number, number, number];
type ColorValue = string | RGB;
const palette = {
red: [255, 0, 0],
green: "#00ff00",
blue: [0, 0, 255],
} satisfies Record<"red" | "green" | "blue", ColorValue>;
const firstChannel: number = palette.red[0];
const uppercaseGreen = palette.green.toUpperCase();
console.log(firstChannel);
console.log(uppercaseGreen);255
#00FF00这里的 palette.red 在目标联合的上下文中推断为三元素数组,而 palette.green 可直接调用字符串方法。不要把这个结果误读为所有值都保持原始字面量:palette.green 是 string,并不是 "#00ff00"。
键集合也很重要。若把目标改为 Record<string, ColorValue>,值仍会接受检查,但 gren 这样的拼写错误会成为一个合法的新键。由程序拥有键集合时,应使用字面量联合、映射类型或已有对象的 keyof。
组合 as const 与 satisfies
如果调用方需要精确值和只读属性,可以先应用 const 断言(const assertion) ,再检查结构。目标中的数组也应声明为 readonly,否则只读元组无法赋给可变数组。
type Role = "viewer" | "editor";
type Command = {
label: string;
roles: readonly Role[];
};
const commands = {
publish: {
label: "Publish",
roles: ["editor"],
},
preview: {
label: "Preview",
roles: ["viewer", "editor"],
},
} as const satisfies Record<"publish" | "preview", Command>;
type CommandName = keyof typeof commands;
function canRun(command: CommandName, role: Role): boolean {
return commands[command].roles.some((allowed) => allowed === role);
}
console.log(canRun("publish", "editor"));
console.log(commands.preview.roles.join(","));
console.log(Object.isFrozen(commands));true
viewer,editor
falsekeyof typeof commands 从真实对象派生出 "publish" | "preview",避免另写一份名称列表。角色数组保持只读元组精度,因此 some() 回调中的 allowed 只可能是该命令实际声明的角色。
最后一行说明 as const 只是静态断言。它不会调用 Object.freeze(),satisfies 也不会增加任何运行时保护。若运行时不可变性属于契约,必须采用冻结、封装或复制等运行时机制。
在数据边界执行真实验证
源码内的默认值适合用 satisfies 检查;解析得到的外部值则先保持为 unknown。类型守卫必须检查容器、判别值和字段类型,而不是只写一个返回类型。
type Settings = {
mode: "safe" | "fast";
retries: number;
};
const defaults = {
mode: "safe",
retries: 2,
} satisfies Settings;
function isSettings(value: unknown): value is Settings {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
(candidate.mode === "safe" || candidate.mode === "fast") &&
typeof candidate.retries === "number"
);
}
const external: unknown = JSON.parse('{"mode":"fast","retries":"3"}');
console.log(isSettings(defaults));
console.log(isSettings(external));true
false外部 JSON 的 retries 是字符串,所以验证器返回 false。如果写成 JSON.parse(raw) satisfies Settings,左侧是 any,表达式通常会通过编译,却不会检查任何字段。这种写法比明显的断言更容易造成“已经验证”的错觉。
示例中的 as Record<string, unknown> 只用于安全读取已经确认是对象的候选属性。每个领域字段随后仍接受显式检查;这个局部断言没有直接把候选值升级为 Settings。
陷阱
修复方法: 让外部数据以 unknown 进入系统,并用解析器、模式验证器或逐字段类型守卫检查。只有验证成功的分支才能构造领域值;satisfies 只检查验证器自身的静态声明。
修复方法: 需要对象对外暴露完整契约,或者稍后要补充可选属性时,使用类型注解。只想验证当前声明并继续派生精确键集合时,使用 satisfies。
修复方法: 先判断调用方真正需要的精度。目标使用字面量联合时可以保留选中的成员;需要整个对象保持精确且只读时使用 as const satisfies Target,不要为了展示窄类型而过度收窄可变状态。
修复方法: 把合法键写成字面量联合,例如 Record<RouteName, Handler>。需要从现有数据派生时,使用 keyof typeof source,让一个声明成为唯一事实来源。
修复方法: 删除断言,修正左侧值或目标契约。确实无法在静态系统中表达的边界,应把断言限制在很小的辅助函数中,并用运行时检查与测试证明该函数的前置条件。
修复方法: 若状态必须在多个合法值之间变化,给变量或属性写出预期的可变类型注解。satisfies 更适合声明后保持稳定,并用于派生键、联合或回调签名的静态表格。
上下文类型会影响推断
类型推断(type inference) 并非只看左侧字面量本身。表达式所在位置提供的预期类型也会影响参数、数组和对象属性的推断,这称为上下文类型推断。satisfies 的右侧就是这样一个上下文来源。
普通声明 const plain = { mode: "safe" } 中,plain.mode 通常拓宽为 string,因为对象属性可被修改。目标为 { mode: "safe" | "fast" } 时,satisfies 提供一个字面量联合上下文,结果可保留 "safe"。目标若只是 { mode: string },则没有可选择的窄联合成员,结果仍通常是 string。
数组也会利用目标上下文。目标分支包含固定长度元组时,数组字面量可以推断为元组,从而保留长度信息。目标只有 number[] 时,数组仍是普通数组;satisfies 不会自动把所有数组变成元组。
回调参数同样可以从目标取得类型。注册表目标若规定 (event: Event) => void,未显式注解的回调参数会被推断为 Event。最终对象仍拥有每个具体回调的推断签名,但这不意味着函数参数方差规则被关闭。
这个机制解释了为什么“检查但绝不改变类型”容易造成误解。准确说法是:目标参与左侧的上下文推断,检查完成后不会用整个目标类型覆盖推断结果。评审时应查看编译器实际显示的类型,而不是从语法口号猜测。
精确键集合与可赋值性
satisfies 使用普通的可赋值性规则,不会引入新的“精确对象类型”。直接对象字面量面对没有索引签名的目标时会执行多余属性检查,所以 Record<"read" | "write", Handler> 能拒绝 wirte。若先把同一个对象存进变量再检查,结构类型的一般规则可能允许额外成员。
必需键检查来自映射类型本身。Record<"read" | "write", Handler> 展开后需要两个属性,缺少其中任何一个都会失败。Record<string, Handler> 只声明“存在的任意字符串属性都必须是 Handler”,并不要求某个具体键存在。
有限键联合应来自最可靠的数据源。若命令对象是事实来源,就从 keyof typeof commands 派生名称;若协议先定义合法名称,就让 Record<CommandName, Command> 检查对象。不要同时手写对象键、联合和验证数组三份列表。
多余属性检查不是运行时白名单。即使源码对象通过精确键检查,JavaScript 仍可在运行时添加属性,外部 JSON 也可以包含额外字段。需要拒绝未知字段时,运行时解析器必须明确执行这一策略。
可选属性不会被补上
目标中的可选属性表示兼容值可以没有该属性。左侧省略它时,satisfies 会接受对象,但结果类型仍只包含实际声明的属性。这样一来,"cache" in config 与 config.cache 的静态可用性不会被目标虚构出来。
若左侧写出可选属性,它的值必须满足当前编译器选项下的可赋值性规则。启用 exactOptionalPropertyTypes 时,option?: string 表示属性存在时必须是 string,不自动包含 undefined。关闭该选项时,显式 undefined 的兼容范围会更宽。
库和应用应在自己的 tsconfig 下验证示例与声明。satisfies 不会隔离 strictFunctionTypes、noUncheckedIndexedAccess 或 exactOptionalPropertyTypes 等选项的影响。复制生成代码时,必须在目标项目的真实配置中运行 tsc。
需要稍后添加可选属性时,注解通常更符合意图。例如,先声明 const config: Config = defaults,再更新 config.cache,公开契约清楚且允许目标定义的变化。用 satisfies 后再通过断言补属性,只是在抵消原本保留精确结果类型的选择。
as const satisfies 有先后顺序
表达式 value as const satisfies Target 先对字面量应用 const 断言,再检查只读、窄化后的结果能否赋给 Target。因此,对象属性会变为只读,数组会成为只读元组,原始值会尽量保持字面量类型。目标必须接受这种只读性。
顺序适合静态常量表,但不适合所有配置。若下游函数拥有数组并会排序或追加元素,向它传递只读元组应当失败;把目标中的数组改成 readonly 只是准确描述不修改的使用方,不能用来掩盖真实的可变需求。
两个操作符都会从生成的 JavaScript 中消失。运行时值仍是普通对象,引用身份与不带这些类型语法时相同。编译器检查通过只证明受检源码表达式与静态目标兼容,不证明对象之后永远不变。
对外发布的常量还要考虑声明输出。保留极窄的推断类型会让生成的 .d.ts 暴露大量具体属性;这可能是有意设计,也可能把实现细节变成公共 API。库边界需要稳定契约时,应给导出写显式注解,并在内部使用 satisfies 检查更具体的实现表。
根据契约与可变性选择语法
类型注解、satisfies、as const 和类型断言并不是四种风格不同的同义写法。
它们分别控制公开契约、兼容性检查、只读字面量推断和检查器信任,因此选择标准应是代码下一步要做什么。
类型注解固定公开类型
变量需要在多个合法状态之间赋值时,注解通常最直接。
let mode: "safe" | "fast" = "safe" 明确表示后续可以写入另一个联合成员,而不是把初始化值当成永久状态。
导出的函数参数、返回值和对象也常需要注解,因为维护者希望实现变化不能悄悄改变使用方看到的类型。 注解造成的拓宽在这里不是信息损失,而是稳定 API 的有意选择。
satisfies 校验当前表达式
静态表格通常先声明一次,之后从中派生名称、判别值或回调类型。 此时保留当前表达式的成员信息有实际价值,而目标类型负责阻止缺失条目和不兼容值。
目标不应比真实契约更宽。
若调用方只支持两个命令,却用 Record<string, Command> 检查,实现得到的是虚假的开放能力,而不是更灵活的设计。
as const 请求只读精度
as const 适合协议常量、测试向量和不会修改的查找表。
它会递归地把字面量表达式中的属性标记为只读,但不会深度冻结值引用的现有对象。
若只需要某一个判别字段保持窄类型,可以给该字段更具体的上下文,而不必冻结整个对象的静态形状。 这种局部设计通常更容易与需要可变集合的代码协作。
类型断言记录外部证明
类型断言只应出现在开发者掌握了编译器无法表达的证据时。 典型位置是经过运行时检查后的窄小适配器,而不是未验证数据刚进入系统的位置。
断言附近应能看到证据来源、前置条件和失败策略。 如果只能用“数据应该是这样”解释断言,就还没有建立足够的证明。
| 语法 | 主要作用 | 结果类型 | 运行时行为 |
|---|---|---|---|
const value: Target = expression | 固定公开契约 | Target | 无额外验证 |
const value = expression satisfies Target | 检查可赋值性 | 上下文中的推断结果 | 无额外验证 |
expression as const | 请求只读字面量精度 | 窄化的只读结果 | 不冻结对象 |
expression as Target | 要求检查器信任断言 | Target | 不验证也不转换 |
用类型测试固定静态保证
satisfies 的价值存在于编译器行为中,只运行 JavaScript 无法验证它。
除了执行示例,还要运行 tsc --noEmit,并把关键的接受与拒绝情况写成类型测试。
正向测试证明可用能力
正向测试应使用检查后保留的具体能力,例如把 routes.health.method 传给只接受 "GET" 的函数。
这比只声明对象更有信息量,因为目标类型意外变宽时,使用点会立即失败。
也可以用 keyof typeof registry 构造名称联合,再让函数只接受该联合。
添加或删除注册项后,类型测试会展示派生 API 是否按预期同步变化。
负向测试证明错误仍被拒绝
使用 @ts-expect-error 标记刻意错误的声明,可以让“应该报错”成为可执行断言。
若未来的重构让该行不再产生诊断,TypeScript 会报告这个指令未被使用。
有限注册表至少应测试一个缺失键、一个多余键和一个错误值。
可变配置还应测试合法重新赋值,防止从注解改成 satisfies 后无意留下过窄类型。
编译器配置属于测试输入
类型测试必须使用项目支持的 TypeScript 版本和 tsconfig。
编辑器中的临时推断不能替代 CI,因为编辑器可能选择另一个工作区版本或不同配置。
验证生成代码时,至少记录以下条件:
- TypeScript 的确切版本,而不是只写“最新版”。
- 是否启用
strict与exactOptionalPropertyTypes。 - 类型测试使用的入口文件和
tsconfig。 - 预期成功与预期失败的命令退出状态。
这些记录能区分语言行为变化、配置差异与代码回归。
只保存运行时快照会漏掉 satisfies 最重要的静态保证。
分层处理注册表与外部数据
成熟系统通常同时有静态注册表和动态输入,两者不应共用一种“验证”方式。 源码注册表由 TypeScript 检查,外部名称与载荷由运行时解析器检查,领域逻辑只接收已验证结果。
静态层拥有实现
处理器对象可以用有限键联合与 satisfies 校验。
这样既保证每个协议命令都有实现,也保留每个处理器的具体参数和返回类型。
从该对象派生 keyof 时,得到的是实现真正拥有的名称。
不要用断言把 Object.keys() 直接升级成任意联合,除非对象的运行时构造路径也保证没有额外键。
边界层拥有解析
解析器接收 unknown,检查输入是否为对象,再验证命令名称和对应载荷。
它可以返回判别联合,让后续控制流根据名称收窄整个请求。
运行时成员检查应来自与静态联合同步的数据源,例如一个 as const 名称数组或模式定义。
只有返回类型而没有实际比较的类型谓词,仍然只是另一种未经证明的断言。
领域层依赖已建立的不变量
领域分派不必重复检查每个基础字段,但应保留不可达分支的运行时失败。 即使静态联合看似穷尽,旧客户端、JavaScript 调用方或错误断言仍可能送入未知名称。
这种分层给 satisfies 一个清晰位置:它检查源码拥有的声明,不承担输入清洗、安全验证或数据迁移。
生成代码把这三层压成一次断言时,应先恢复边界,再讨论类型精度。
延伸阅读
4个问题 · 1 道输出预测题 · 1 道找错题