TypeScript 5.0 中用 satisfies 校验配置对象并保留字面量推断

项目里几乎都有一个 config.ts,里面导出各种配置对象:路由表、主题变量、构建参数、feature flag。这类对象的诉求很特别——既要求结构被编译器严格校验,又要求字段值保留字面量类型。用类型注解写,校验有了,字面量类型却被拓宽成 string / number;用 as const 写,字面量保住了,却完全没有结构约束。TypeScript 5.

项目里几乎都有一个 config.ts,里面导出各种配置对象:路由表、主题变量、构建参数、feature flag。这类对象的诉求很特别——既要求结构被编译器严格校验,又要求字段值保留字面量类型。用类型注解写,校验有了,字面量类型却被拓宽成 string / number;用 as const 写,字面量保住了,却完全没有结构约束。TypeScript 5.0 引入的 satisfies 运算符正是为此设计的:它在不改变被检查表达式推断类型的前提下,对表达式做一次类型兼容性检查。本文围绕一个真实场景,拆解 satisfies 在配置对象上的用法、边界与常见坑。

先复现问题:注解与 as const 的两种失败

假设我们在写一个日志模块的配置,需要按 level 索引到颜色与权重,同时希望 level 字段的字面量类型能被下游 switch 收窄。先看类型注解的写法:

type LogLevel = 'debug' | 'info' | 'warn' | 'error';

interface LoggerConfig {
  level: LogLevel;
  color: string;
  weight: number;
  enabled: boolean;
}

// 写法一:类型注解
const config: LoggerConfig = {
  level: 'info',
  color: '#3b82f6',
  weight: 10,
  enabled: true,
};

// config.level 的类型是 LogLevel,不是 'info'
type Level = typeof config.level; // "debug" | "info" | "warn" | "error"

这里的 config.level 被拓宽成了整个联合类型。第二层损失更隐蔽:如果配置是一个嵌套的映射对象,注解会把它推断成带索引签名的结构,键名丢失。

// 写法二:as const
const configAsConst = {
  level: 'info',
  color: '#3b82f6',
  weight: 10,
  enabled: true,
} as const;

// 有字面量类型,但完全没校验:下面这行不会报错
const bad = {
  level: 'verbose',
  color: '#fff',
  weight: '10',
  enabled: 'yes',
} as const;

两难就在这里:注解负责校验但丢精度,as const 保留精度但不校验。实际项目里常见的妥协是”注解 + 单独的常量联合类型”,可维护成本高,键名一旦变化要靠肉眼看。而 satisfies 把两者合并成一次操作。

satisfies 的基本语义与三次失败场景

satisfies 的语法是 expr satisfies Type,表达式的类型就是 expr 自身的推断类型,Type 只参与兼容性检查。改写上面的例子:

type LogLevel = 'debug' | 'info' | 'warn' | 'error';

interface LoggerConfig {
  level: LogLevel;
  color: `#${string}`;
  weight: number;
  enabled: boolean;
}

const config = {
  level: 'info',
  color: '#3b82f6',
  weight: 10,
  enabled: true,
} satisfies LoggerConfig;

// 保留字面量类型
type Level = typeof config.level;      // "info"
type Weight = typeof config.weight;    // 10
type Color = typeof config.color;      // "#3b82f6"

注意 satisfies 不会把对象变成 readonly,config.weight = 20 依旧合法(因为 weight 推断为 number 的拓宽形态时可变,推断为字面量 10 时对该属性的赋值会被限制在 10,这点稍后细说)。真正变化的是:结构被检查了,字段精度也被保留了。

下面逐一验证它到底能挡住哪些错误。注意这些错误码是 tsc 真实的输出。

场景一:字面量不在联合类型内

const c1 = {
  level: 'verbose',
  color: '#000000',
  weight: 1,
  enabled: false,
} satisfies LoggerConfig;

报错信息:

error TS1360: Type '{ level: "verbose"; color: string; weight: number; enabled: boolean; }'
does not satisfy the expected type 'LoggerConfig'.
  Types of property 'level' are incompatible.
    Type '"verbose"' is not assignable to type 'LogLevel'.

关键在 TS1360 这个错误码:它是 satisfies 专属的检查失败码,与赋值时的 TS2322 区分开,排查时可以直接按码过滤。

场景二:模板字面量类型校验颜色格式

const c2 = {
  level: 'info',
  color: 'rgb(0,0,0)',
  weight: 1,
  enabled: true,
} satisfies LoggerConfig;
error TS1360: Type '{ level: "info"; color: string; weight: number; enabled: boolean; }'
does not satisfy the expected type 'LoggerConfig'.
  Types of property 'color' are incompatible.
    Type '"rgb(0,0,0)"' is not assignable to type '`#${string}`'.

这里暴露了一个容易误判的细节:对象字面量直接传给 satisfies 时,属性值会被”新鲜地”检查,所以 'rgb(0,0,0)' 能被识别出来;但如果先把值存进一个 string 变量再引用,string 不能赋给 `#${string}`,报错会转移到变量处。写配置时尽量内联字面量。

场景三:缺失属性与多余属性

const c3 = {
  level: 'warn',
  color: '#f59e0b',
  enabled: true,
} satisfies LoggerConfig;
error TS1360: Type '{ level: "warn"; color: "#f59e0b"; enabled: true; }'
does not satisfy the expected type 'LoggerConfig'.
  Property 'weight' is missing in type
  '{ level: "warn"; color: "#f59e0b"; enabled: true; }'
  but required in type 'LoggerConfig'.

对于”多余属性”,行为与赋值语境一致,走的是 excess property check:

const c4 = {
  level: 'info',
  color: '#000000',
  weight: 1,
  enabled: true,
  retries: 3,
} satisfies LoggerConfig;
error TS1360: Type '{ level: "info"; color: string; weight: number; enabled: boolean; retries: number; }'
does not satisfy the expected type 'LoggerConfig'.
  Object literal may only specify known properties,
  and 'retries' does not exist in type 'LoggerConfig'.

实战:用 satisfies 校验路由表并保持键与值的精度

配置对象里 satisfies 最能发挥价值的地方,是那些”键名需要在别处被引用、值需要被穷尽检查”的场景。下面用一个路由表把它写完整。

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';

interface RouteMeta {
  path: string;
  method: HttpMethod;
  auth: boolean;
  cacheSeconds: number;
}

// 值的结构与 RouteMeta 一致,键名任意
const routes = {
  userList: { path: '/api/users', method: 'GET', auth: true, cacheSeconds: 60 },
  userCreate: { path: '/api/users', method: 'POST', auth: true, cacheSeconds: 0 },
  health: { path: '/api/health', method: 'GET', auth: false, cacheSeconds: 0 },
} satisfies Record;

// 键名被精确保留,可以当联合类型使用
type RouteName = keyof typeof routes; // "userList" | "userCreate" | "health"

function buildUrl(name: RouteName): string {
  return routes[name].path;
}

// 编译期即可发现拼写错误
buildUrl('userlist');
error TS2345: Argument of type '"userlist"' is not assignable to parameter of type
'"userList" | "userCreate" | "health"'.

这里 satisfies Record<string, RouteMeta> 是个小心机:它只约束”每个值都要符合 RouteMeta”,而不强迫键名必须是某个固定集合。如果用 const routes: Record<string, RouteMeta>,keyof 会退化成 string,buildUrl 的参数校验就形同虚设。这是注解与 satisfies 在映射对象上最本质的差别。

进一步:让方法名与 HTTP 语义一致

如果希望某些路由的 method 被固定,可以把约束写成更精确的映射类型,让 satisfies 与它配合:

const fixedRoutes = {
  health: { path: '/api/health', method: 'GET', auth: false, cacheSeconds: 0 },
  metrics: { path: '/api/metrics', method: 'GET', auth: false, cacheSeconds: 30 },
} satisfies Record;

// 值精度保留,可以据此推导
type HealthCache = typeof fixedRoutes.health.cacheSeconds; // 0

运行时校验不能省

必须强调:satisfies 是纯类型层面的工具,编译后完全消失。它防不住外部 JSON 输入。配置来自远程或环境变量时,仍需运行时校验:

function assertRouteMeta(input: unknown): asserts input is RouteMeta {
  if (typeof input !== 'object' || input === null) {
    throw new TypeError('route meta must be an object');
  }
  const o = input as Record<unknown, unknown>;
  if (typeof o.path !== 'string') throw new TypeError('path must be string');
  if (!['GET', 'POST', 'PUT', 'DELETE'].includes(o.method as string)) {
    throw new TypeError(`invalid method: ${String(o.method)}`);
  }
  if (typeof o.auth !== 'boolean') throw new TypeError('auth must be boolean');
  if (typeof o.cacheSeconds !== 'number' || o.cacheSeconds < 0) {
    throw new TypeError('cacheSeconds must be a non-negative number');
  }
}

进阶技巧:用 satisfies 驱动枚举、CSS 变量与类型收窄

配置对象除了被读取,经常还要被”反向使用”:用它的键生成 UI 选项,用它的值生成 switch 分支。这时字面量精度直接决定下游类型的安全性。

生成下拉选项数组

type LogLevel = 'debug' | 'info' | 'warn' | 'error';

const levelStyles = {
  debug: { color: '#94a3b8', rank: 0 },
  info: { color: '#3b82f6', rank: 1 },
  warn: { color: '#f59e0b', rank: 2 },
  error: { color: '#ef4444', rank: 3 },
} satisfies Record<LogLevel, { color: `#${string}`; rank: number }>;

type LevelStyle = (typeof levelStyles)[LogLevel];

const levelOptions = (Object.keys(levelStyles) as Array<keyof typeof levelStyles>)
  .map((k) => ({ value: k, color: levelStyles[k].color }));

如果这里写成 const levelStyles: Record<LogLevel, LevelStyle>,Object.keys 仍然返回 string[],需要同样的断言,但内部的 color 会退化成 string,不能直接喂给要求 `#${string}` 的函数(例如某些 CSS-in-JS 的强类型 API)。

让 switch 的 default 分支具备可穷尽性

function toCssVar(level: keyof typeof levelStyles): string {
  return `--log-${level}-color`;
}

// 穷尽检查:少写一个分支时,never 位置会报错
function rank(l: keyof typeof levelStyles): number {
  switch (l) {
    case 'debug': return levelStyles[l].rank;
    case 'info': return levelStyles[l].rank;
    case 'warn': return levelStyles[l].rank;
    case 'error': return levelStyles[l].rank;
    default: {
      const exhaustive: never = l;
      throw new Error(`unhandled level: ${String(exhaustive)}`);
    }
  }
}

因为 satisfies Record<LogLevel, ...> 已经保证四个键都存在,keyof typeof levelStyles 恰好是 LogLevel,default 分支里 l 被收窄到 never。少了键会先在 satisfies 处报 TS1360,多了键也会在对象字面量处报错,两头的口子都被堵住。

与 as const 组合,得到只读的精确配置

需要深度只读时可以把 as const 和 satisfies 叠加,注意顺序必须是先 as const 再 satisfies:

const theme = {
  primary: '#2563eb',
  radius: 8,
  modes: ['light', 'dark'],
} as const satisfies {
  primary: `#${string}`;
  radius: number;
  modes: readonly string[];
};

type Radius = typeof theme.radius;   // 8
theme.radius = 16;
error TS2540: Cannot assign to 'radius' because it is a read-only property.

反过来写成 {...} satisfies X as const 在语法上不成立,因为 as const 作用在 satisfies 表达式的结果上虽然可行,但会得到一个断言后的只读类型,且校验仍按之前推断出的拓宽类型进行,达不到预期,实践中统一用 as const satisfies 的顺序。

踩坑清单:satisfies 的几个反直觉行为

  • 不是类型断言,不能”骗过”检查。 { level: 'verbose' } satisfies LoggerConfig 直接报错,不能像 as LoggerConfig 那样强制通过。这是优点,但迁移旧代码时原先靠 as 越过的错误会全部暴露出来。
  • 不会改变变量的可写性,但会限制字面量属性的赋值。 const c = { weight: 10 } satisfies { weight: number },c.weight 的类型是 number 还是 10?在 TS 5.0 中,satisfies 表达式的类型是源表达式的推断类型,对象字面量的属性会被拓宽(weight 为 number),因此 c.weight = 20 合法。但若外层是 as const satisfies,属性变只读,赋值报 TS2540。这个差异在读代码时容易误判。
  • 对函数参数位置无意义。 satisfies 只作用于表达式,不能替代参数注解。function f(cfg: LoggerConfig) 里传对象时,精度本来就丢失,要保留精度得改用泛型 function f<T extends LoggerConfig>(cfg: T)。
  • 泛型场景下不参与推断。 satisfies 的类型参数不会反向注入到表达式内部,不要指望它做双向推断。像 createRouter({...} satisfies Routes) 这种写法,推断源仍是 createRouter 自身的泛型签名。
  • 报错码不同。 用 TS1360 定位 satisfies 失败,TS2322 是普通赋值错误,CI 里按码过滤告警时不要搞混。若看到 TS1360: Type ... does not satisfy the expected type,先检查被检查对象是否被某个中间变量拓宽了。
  • 版本要求。 satisfies 需要 TypeScript ≥ 5.0;若 tsc --version 显示 4.x,编辑器会报 TS1005: ';' expected 之类的语法错。升级 typescript 依赖并重启语言服务(VS Code 中执行 TypeScript: Restart TS Server)即可。

一个高频真实报错及其修复

把配置抽成独立文件后,常见下面这种失败:

// config.ts
export const settings = {
  env: process.env.NODE_ENV ?? 'development',
  port: Number(process.env.PORT ?? 3000),
} satisfies { env: 'development' | 'production'; port: number };
error TS1360: Type '{ env: string; port: number; }'
does not satisfy the expected type '{ env: "development" | "production"; port: number; }'.
  Types of property 'env' are incompatible.
    Type 'string' is not assignable to type '"development" | "production"'.

原因是 process.env.NODE_ENV 的类型是 string | undefined,?? 之后仍是 string,推断不出字面量。修法是显式窄化,而不是把 satisfies 换成 as:

const rawEnv = process.env.NODE_ENV;
const env = rawEnv === 'production' ? 'production' : 'development';

export const settings = {
  env,
  port: Number(process.env.PORT ?? 3000),
} satisfies { env: 'development' | 'production'; port: number };

这样 env 被三元表达式收窄成 'production' | 'development',satisfies 顺利通过;同时外部输入依然经过了显式判断,运行时也不会出现意外值。这正体现了 satisfies 的定位:它是类型层的守门员,不是运行时清洗器。

小结

satisfies 解决的是”校验与精度不可兼得”这一具体矛盾。它把结构约束交给右侧的类型,把推断结果留给左侧的表达式,从而让配置对象的键名、字面量值、模板字面量格式都能被下游精确消费。使用时的判断标准很简单:当你想给一个对象加类型约束,但下游还需要它的键名或字面量值时,写 satisfies;当你不关心推断结果、只要结构匹配时,普通类型注解就够用;当你需要深度只读时,用 as const satisfies 的顺序。至于运行时数据,永远别指望 satisfies,该写的校验函数一行都不能少。

FAQ

satisfies 和类型注解能互相替代吗?

多数场景可以,但注解会把变量的类型替换成注解类型,从而丢失字面量与键名精度。satisfies 只做兼容检查,不替换推断类型。当精度重要时(keyof、switch 穷尽、模板字面量校验)用 satisfies 更合适。

为什么我的对象用 satisfies 却仍被推成了 string?

检查该属性值是否来自变量或函数调用。字面量直接写在对象里才会被保留为字面量类型;const s = 'info' 再引用 s,其类型是 string(const 变量在对象属性位置会被拓宽),此时 satisfies 也救不回来。解决方式是内联字面量,或用 as const 固定中间变量(const s = 'info' as const)。

satisfies 会不会影响编译产物或运行性能?

不会。它是纯类型运算符,编译后整段语法被擦除,产物与直接写对象字面量完全一致,也没有任何运行时检查开销。

能在 .d.ts 或接口里用 satisfies 吗?

不能。satisfies 只能出现在表达式位置,接口、类型别名、声明文件里的类型声明都不支持。需要把校验结果导出给其他模块时,导出 typeof 表达式得到的类型即可,例如 export type RouteName = keyof typeof routes。

升级到 TypeScript 5.0 后旧代码编译报 TS1360 是怎么回事?

说明项目里原本就有类型不匹配、只是被 as 断言或 any 掩盖的问题,恰好有人改用了 satisfies。逐条按报错定位真实错误并修复,不要退回去改 as,否则等于把校验重新关掉。