← 返回文章

JavaScript / 前端工程 / TypeScript

TypeScript 类型收窄与 satisfies:让前端配置既安全又好用

TypeScript 项目里有两种常见极端:一种到处写 as SomeType,编译器被强行说服,错误数据顺利进入运行时;另一种给所有对象都加宽泛类型注解,虽然通过校验,却丢失了字面量和属性推断,后续使用反而到处需要判断。

真正实用的类型安全不是“类型写得越多越好”,而是让编译器根据运行时证据逐步收窄,并在配置边界使用 satisfies 校验结构,同时保留表达式原本的具体类型。

一、类型断言不是数据验证

假设接口返回用户信息:

1
2
3
4
5
6
7
type User = {
id: string;
name: string;
};

const response = await fetch('/api/user');
const user = (await response.json()) as User;

as User 不会检查 JSON 里是否真的有 idname。它只是在编译阶段告诉 TypeScript:“相信我。”服务端返回 null 或字段改名时,运行时仍会出错。

边界数据应先作为 unknown 处理,再通过真实检查收窄:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function isUser(value: unknown): value is User {
if (typeof value !== 'object' || value === null) return false;

return (
'id' in value &&
typeof value.id === 'string' &&
'name' in value &&
typeof value.name === 'string'
);
}

const data: unknown = await response.json();

if (!isUser(data)) {
throw new Error('Invalid user response');
}

console.log(data.name);

类型谓词 value is User 只有在函数确实验证了对应条件时才可信。如果内部无条件返回 true,它和类型断言一样危险。

二、理解控制流收窄

TypeScript 会根据 JavaScript 条件判断推导更具体的类型:

1
2
3
4
5
6
7
8
9
function formatValue(value: string | number | null) {
if (value === null) return '—';

if (typeof value === 'number') {
return value.toFixed(2);
}

return value.trim();
}

经过 value === nulltypeof 判断后,每个分支只剩下合法操作。相比在每行后面加 !as string,控制流收窄同时表达了运行时逻辑。

常用证据包括:

  • typeof value === 'string'
  • value instanceof Date
  • 'property' in value
  • 严格相等比较
  • 数组与对象的自定义类型守卫
  • 可辨识联合中的字面量字段

真值判断要谨慎:

1
2
3
if (value) {
// 这里会同时排除空字符串、0、false、null 和 undefined
}

如果 0 是合法价格,应该写 value !== null && value !== undefined,而不是笼统依赖 truthy。

三、用可辨识联合表达 UI 状态

加载、成功和失败状态如果使用多个可选字段,很容易产生非法组合:

1
2
3
4
5
type BadState = {
loading: boolean;
data?: Product[];
error?: string;
};

loading: truedataerror 可以同时存在,组件必须猜哪个优先。改用可辨识联合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type ProductState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: Product[] }
| { status: 'error'; message: string };

function ProductPanel({ state }: { state: ProductState }) {
switch (state.status) {
case 'idle':
return null;
case 'loading':
return <Spinner />;
case 'success':
return <ProductList products={state.data} />;
case 'error':
return <ErrorMessage>{state.message}</ErrorMessage>;
}
}

status 是辨识字段。进入 success 分支后,data 自动可用;进入 error 分支后,只有 message 合法。类型设计直接阻止了不可能状态。

还可以加入穷尽检查,让新增状态时编译失败:

1
2
3
4
5
6
7
8
9
10
11
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}

function renderState(state: ProductState) {
switch (state.status) {
// 处理已有分支
default:
return assertNever(state);
}
}

四、satisfies 解决了什么问题

TypeScript 4.9 引入 satisfies,用于检查一个表达式是否匹配某个类型,同时保留表达式自身更具体的推断。

先看普通类型注解:

1
2
3
4
5
6
7
8
9
type RouteConfig = {
path: string;
requiresAuth: boolean;
};

const routes: Record<string, RouteConfig> = {
home: { path: '/', requiresAuth: false },
account: { path: '/account', requiresAuth: true },
};

routes 被注解为任意字符串键的记录,后续写 routes.notExist 也可能被认为是一个 RouteConfig。使用 satisfies

1
2
3
4
5
6
7
const routes = {
home: { path: '/', requiresAuth: false },
account: { path: '/account', requiresAuth: true },
} satisfies Record<string, RouteConfig>;

routes.account.path; // 正确
// routes.notExist; // 编译错误

对象会被校验每个条目都符合 RouteConfig,同时 homeaccount 这些实际键仍被保留。

五、前端配置中的实用例子

设计令牌常常既要检查值格式,又希望保留具体键名:

1
2
3
4
5
6
7
8
9
10
11
type HexColor = `#${string}`;

const colors = {
text: '#1f2328',
primary: '#155eef',
danger: '#b42318',
} satisfies Record<string, HexColor>;

function getDangerColor() {
return colors.danger;
}

如果误写成 danger: 'red',结构检查会报错;colors.danger 又保持为可直接访问的已知属性。

菜单配置也适合:

1
2
3
4
5
6
7
8
9
10
type MenuItem = {
label: string;
href: `/${string}`;
icon?: 'home' | 'chart' | 'settings';
};

const menu = [
{ label: '首页', href: '/', icon: 'home' },
{ label: '报表', href: '/reports', icon: 'chart' },
] satisfies readonly MenuItem[];

satisfies 只存在于类型系统,不会在运行时验证从接口、localStorage 或 URL 读取的数据。静态配置可以靠它检查;外部输入仍需要解析器或类型守卫。

六、satisfies、类型注解和 as 怎么选

可以用下面的原则判断:

  • 类型注解:变量的公共类型就是重点,希望后续赋值都以该类型为准。
  • satisfies:需要校验表达式符合约束,但希望保留具体键名与字面量推断。
  • as const:希望值变成只读并尽量保留字面量。
  • as Type:你掌握了编译器不知道的事实,并且能说明为什么安全;不用于跳过边界验证。

as constsatisfies 可以组合:

1
2
3
4
5
const breakpoints = {
mobile: 0,
tablet: 768,
desktop: 1200,
} as const satisfies Record<string, number>;

这既检查所有值是 number,又保留 0 | 768 | 1200 的字面量和只读属性。

七、常见踩坑

  1. satisfies 当成运行时 schema 校验。
  2. 在类型守卫里只检查对象存在,没有检查字段类型。
  3. 用非空断言 ! 掩盖异步数据尚未加载。
  4. 用数组索引访问后直接断言元素存在,忽略越界。
  5. 可辨识联合仍保留大量可选字段,失去“不可能状态不可表示”的价值。
  6. 为了消除错误把 unknown 改成 any,同时关闭了编译器保护。

八、行动清单

  • 从接口、URL 和存储读取的数据是否先按 unknown 处理?
  • UI 状态能否改成带 status 的可辨识联合?
  • 配置对象是否因为宽泛注解丢失了具体键名?
  • 现有 as! 是否有真实运行时证据支撑?
  • switch 在新增联合成员后能否触发穷尽错误?

TypeScript 的价值不在于让代码里出现更多类型符号,而在于把真实业务分支变成编译器能够理解的证据。用控制流完成收窄,用联合类型排除非法状态,用 satisfies 检查静态配置,再把运行时边界交给真正的验证逻辑,类型安全才不会停留在“看起来像安全”。

参考资料