TypeScript satisfies 与 as const 的工程化实践:从类型推导到不可变契约的 5 个关键场景
前言
TypeScript 4.9 引入的 satisfies 关键字,加上早已存在的 as const(const 断言),构成了现代 TypeScript 工程化的两个关键拼图。然而,很多开发者仍停留在"听说过但没用过"的阶段,或者在错误场景中使用。
本文将用 5 个真实场景,逐一拆解它们的最佳实践与反模式。
场景一:satisfies — 保留最窄类型的同时做类型检查
❌ 反模式:类型注解丢失了字面量信息
// 用类型注解:颜色名被拓宽为 string
type Theme = Record<string, { bg: string; text: string }>;
const theme: Theme = {
primary: { bg: "#1a73e8", text: "#ffffff" },
danger: { bg: "#dc3545", text: "#ffffff" },
};
// theme.primary 的类型是 { bg: string; text: string }
// 丢失了 "primary" | "danger" 的 key 信息
// Object.keys(theme) → string[],而不是 ("primary" | "danger")[]
❌ 反模式:裸对象得不到类型检查
const theme = {
primary: { bg: "#1a73e8", text: "#ffffff" },
danger: { bg: "#dc3545", txet: "#ffffff" }, // 拼写错误!不会报错
};
// 虽然保留了字面量类型,但没有任何结构约束
✅ 最佳实践:satisfies 兼得两者
const theme = {
primary: { bg: "#1a73e8", text: "#ffffff" },
danger: { bg: "#dc3545", text: "#ffffff" },
} satisfies Theme;
// ✓ 类型检查通过
// ✓ theme.primary.bg 推断为 "#1a73e8"(字面量类型)
// ✓ Object.keys(theme) 推断为 ("primary" | "danger")[]
// 如果写成 satisfies Record<string, { bg: string; text: string }>:
// danger: { bg: "#dc3545", txet: "#ffffff" } ← 编译报错!
关键认知:satisfies 不改变变量的推断类型,只在赋值时做类型检查。这让它比类型注解更"轻量",比裸对象更"安全"。
场景二:as const — 从可变到不可变契约
❌ 反模式:魔法字符串散落各处
// 分散在多个文件中的字符串字面量
function navigate(path: string) { /* ... */ }
function checkPermission(role: string) { /* ... */ }
navigate("/dashboard"); // 拼写错误不会报错
checkPermission("adminn"); // 拼写错误不会报错
✅ 最佳实践:as const 创建类型级枚举
const ROUTES = {
DASHBOARD: "/dashboard",
SETTINGS: "/settings",
PROFILE: "/profile",
} as const;
const ROLES = ["admin", "editor", "viewer"] as const;
type Route = (typeof ROUTES)[keyof typeof ROUTES];
// → "/dashboard" | "/settings" | "/profile"
type Role = (typeof ROLES)[number];
// → "admin" | "editor" | "viewer"
function navigate(path: Route) { /* ... */ }
function checkPermission(role: Role) { /* ... */ }
navigate("/dashbaord"); // ❌ 编译报错!
checkPermission("adminn"); // ❌ 编译报错!
关键认知:as const 将对象属性变为 readonly 字面量类型,数组变为 readonly 元组。结合 typeof 查询,可以在不写重复类型定义的前提下获得完整的联合类型。
场景三:satisfies + as const 协同 — 配置对象的终极形态
❌ 反模式:类型注解 + 魔法值
interface FeatureFlag {
key: string; // 应该是联合类型
default: boolean;
description: string;
}
const FEATURES: FeatureFlag[] = [
{ key: "darkMode", default: false, description: "Dark mode" },
{ key: "betaUi", default: false, description: "Beta UI" },
];
// FEATURES[0].key 类型是 string —— 没有任何约束力
// 无法从 flags 推导出 key 的联合类型
✅ 最佳实践:satisfies + as const 组合拳
interface FeatureFlagDef {
key: string;
default: boolean;
description: string;
}
const FEATURES = [
{ key: "darkMode", default: false, description: "Dark mode" },
{ key: "betaUi", default: false, description: "Beta UI" },
] as const satisfies readonly FeatureFlagDef[];
// ✓ 数组结构通过 FeatureFlagDef 检查
// ✓ 每个元素保留字面量类型(as const)
// ✓ 可以推导出 FeatureKey 联合类型
type FeatureKey = (typeof FEATURES)[number]["key"];
// → "darkMode" | "betaUi"
// 现在可以在整个代码库中使用类型安全的 feature flag
const featureState: Record<FeatureKey, boolean> = {
darkMode: true,
betaUi: false,
// newFeature: true ← 类型报错:不存在于 FEATURES 中
};
关键认知:as const satisfies T 的顺序很重要:先 as const 保证字面量类型,再 satisfies 做结构检查。这是配置驱动架构的类型安全基石。
场景四:satisfies 与联合类型的穷尽性检查
❌ 反模式:switch-case 遗漏处理
type EventType = "click" | "focus" | "blur" | "keydown";
function handleEvent(type: EventType) {
switch (type) {
case "click": return trackClick();
case "focus": return trackFocus();
// 遗漏了 blur 和 keydown —— 没有任何提示
}
}
✅ 最佳实践:satisfies + never 穷尽性检查
const EVENT_HANDLERS = {
click: () => trackClick(),
focus: () => trackFocus(),
blur: () => trackBlur(),
keydown: () => trackKeydown(),
} satisfies Record<EventType, () => void>;
// 如果遗漏任何一个 EventType 的 key,satisfies 会报错!
function handleEvent(type: EventType) {
return EVENT_HANDLERS[type]();
// 不需要 switch-case,类型安全且零遗漏
}
进阶:如果需要处理函数中的其他逻辑,可以将 satisfies 检查与 Record 结合,确保映射表始终与联合类型同步:
// 当新增 EventType 时:
type EventType = "click" | "focus" | "blur" | "keydown" | "doubleClick";
// EVENT_HANDLERS 会立即报错:缺少 "doubleClick" 处理器
// 强制开发者处理所有分支
场景五:satisfies 在泛型约束中的妙用
❌ 反模式:过度使用 extends 导致类型缩窄
function createFormConfig<T extends Record<string, { label: string; value: unknown }>>(
config: T
): T {
return config;
}
const form = createFormConfig({
name: { label: "Name", value: "" },
age: { label: "Age", value: 0 },
});
// form.name.value 推断为 string ✓
// form.age.value 推断为 number ✓
// 但泛型约束需要手动编写,重复度高
✅ 最佳实践:用 satisfies 替代简单场景的泛型
interface FieldConfig {
label: string;
value: unknown;
}
const form = {
name: { label: "Name", value: "" },
age: { label: "Age", value: 0 },
active: { label: "Active", value: true },
} satisfies Record<string, FieldConfig>;
// form.name.value → "" (字面量类型)
// form.age.value → 0 (字面量类型)
// form.active.value → true (字面量类型)
// 无需泛型函数包装,直接获得完整的类型推导
核心决策矩阵
| 场景 | 推荐方式 | 理由 |
|---|---|---|
| 需要类型检查 + 保留窄类型 | satisfies | 兼得类型安全和字面量推导 |
| 创建不可变常量集合 | as const | 锁定字面量类型和 readonly |
| 配置对象(检查 + 不可变 + 推导) | as const satisfies T | 三重保障 |
| 需要类型拓宽/向上转型 | 类型注解 : T | 明确意图,避免意外窄类型 |
| 运行时验证 | Zod / valibot | satisfies 只在编译期生效 |
总结
satisfies 和 as const 代表了 TypeScript 类型系统的两种互补哲学:
- satisfies:"我满足这个契约,但我仍然是我自己"——保留类型身份的同时接受结构检查
- as const:"我是不可变的,请以最精确的方式认识我"——锁定字面量类型,构建类型级枚举
两者的协同使用(as const satisfies)是配置驱动开发、类型安全映射表、穷尽性检查等场景的标准范式。将这些模式融入日常编码,你会在编译期就能捕获大量原本需要测试或运行时才能发现的错误。
编译期的错误是最便宜的错误——它们不会到达用户。
0 评论
评论区
登录 后参与评论