03 · TypeScript 进阶:类型体操与 Dart 对照
本篇解决什么问题: 你在 Flutter 里用 Dart,但类型系统的心智模型还是 TS 的。 本篇帮你把两边的类型能力对照打通,并回答一个关键问题: 类型体操到底该学到什么程度?
📌 与 01 篇的分工: Flutter 01 篇 是 TS → Dart 的基础语法对照(变量、空安全、异步); 本篇是类型系统层面的进阶对照(泛型、条件类型、模式匹配),并反过来用 Dart 加深你对 TS 的理解。
⚠️ 易变信息:TS 版本特性(如
satisfies、const类型参数)演进较快, 按本库约定以官方文档为准;本篇讲稳定的类型系统原理与判断框架。
开篇:类型系统对照(先建立地图)
| 能力 | TypeScript | Dart |
|---|---|---|
| 泛型 | ✅ Foo<T> | ✅ Foo<T> |
| 泛型约束(上界) | ✅ T extends X | ✅ T extends X |
| 运行时保留泛型 | ❌ 擦除 | ✅ 保留(reified) |
| 条件类型 | ✅ T extends U ? X : Y | ❌ 无 |
| 映射类型 | ✅ {[K in keyof T]: ...} | ❌ 无 |
infer 推断 | ✅ | ❌ 无 |
| 模板字面量类型 | ✅ `a${T}` | ❌ 无 |
| 联合类型 | ✅ A | B | ⚠️ 需 sealed class 模拟 |
| 类型守卫 | ✅ is / typeof / in | ✅ is / switch 模式匹配 |
| 判别联合 | ✅ discriminated union | ✅ sealed class + 模式匹配 |
| "从值取类型" | ✅ typeof / keyof | ❌ 无(靠代码生成) |
| 不可变数据 | readonly / as const | @freezed / const |
一句话总结差异:
TS 的类型系统是"编译期的图灵完备语言"(能做类型体操),但运行时完全消失。Dart 的类型系统是"运行时真实存在的",但编译期表达能力弱得多。
这个根本差异决定了两边完全不同的设计取向——见第一节。
一、根本差异:类型擦除 vs 类型具体化
这是理解两边一切类型差异的钥匙。
1.1 TypeScript:类型擦除(Type Erasure)
const list: number[] = [1, 2, 3];
// 编译后:
const list = [1, 2, 3]; // 类型信息完全消失
console.log(list instanceof Array); // true
console.log(list instanceof Array<number>); // ❌ 语法错误!运行时没有 Array<number>后果:
| 现象 | 原因 |
|---|---|
无法在运行时判断 List<number> 还是 List<string> | 运行时只有 Array |
instanceof 只能判断类,不能判断泛型参数 | 类型已擦除 |
需要运行时类型信息时,必须手动传参(如 new Map<string, Foo>(String, Foo) 这种写法在 TS 里做不到) | 只能靠额外字段 |
解法(TS 的常用 workaround):
// 用一个"类型标签"字段在运行时区分
type Result<T> =
| { kind: 'ok'; value: T }
| { kind: 'err'; message: string };
function handle(r: Result<number>) {
if (r.kind === 'ok') { /* ... */ } // ← 靠 kind 字段判别
}这个模式叫 判别联合(Discriminated Union)—— 用运行时存在的字段(kind)来弥补类型擦除。
1.2 Dart:类型具体化(Reified Generics)
final list = <int>[1, 2, 3];
print(list is List<int>); // ✅ true(运行时真能判断)
print(list is List<String>); // ✅ false
print(list is List); // trueDart 的泛型在运行时是真实存在的,所以:
| 能力 | Dart | TS |
|---|---|---|
x is List<int> | ✅ | ❌ |
| 反射拿到泛型参数 | ✅(dart:mirrors / 部分通过 runtimeType) | ❌ |
代价:运行时要携带类型信息,有一点点开销(通常可忽略)。
1.3 这个差异带来的设计取向不同
| TypeScript | Dart | |
|---|---|---|
| 类型系统定位 | 编译期的"元编程语言" | 运行时的真实约束 |
| 强项 | 表达复杂类型关系(类型体操) | 运行时类型判断、模式匹配 |
| 复杂类型关系怎么做 | 靠类型运算(条件类型、映射类型) | 靠类继承 + 模式匹配 |
| "从数据结构推导类型" | 类型体操 | 代码生成(Freezed / json_serializable) |
💡 这解释了为什么 Dart 生态重度依赖代码生成(build_runner): 因为 Dart 的编译期类型表达能力弱,
copyWith、fromJson、联合类型这些 TS 能用类型体操搞定的事,Dart 只能生成代码。你在项目里用的 Freezed(见 Flutter 08 篇)就是这个路线的产物。
二、TS 类型体操四件套
先说清楚:这些 Dart 里都没有。学它们是为了写更好的 TS, 顺便理解"为什么 Dart 不需要这些"。
① keyof / typeof:从值提取类型
const config = { host: 'a.com', port: 8080, debug: true };
type Config = typeof config; // { host: string; port: number; debug: boolean }
type ConfigKey = keyof Config; // 'host' | 'port' | 'debug'
function get<K extends keyof Config>(key: K): Config[K] {
return config[key];
}
get('host'); // ✅ 返回 string
get('nope'); // ❌ 编译错误价值:单一数据源——改了 config 对象,所有相关类型自动跟着变。
② 条件类型:T extends U ? X : Y
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<123>; // false
// 实战:从 Promise 里取出值类型
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type C = Unwrap<Promise<number>>; // number
type D = Unwrap<string>; // string价值:让类型随输入变化,写出"会计算"的类型。
③ infer:在条件类型里"捕获"类型
// 取出数组的元素类型
type ElementOf<T> = T extends (infer E)[] ? E : never;
type E1 = ElementOf<number[]>; // number
type E2 = ElementOf<string>; // never
// 取出函数的返回类型
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;价值:从已有类型中提取信息,避免重复写类型。
④ 映射类型:批量变换
// 把所有属性变成可选
type Partial<T> = { [K in keyof T]?: T[K] };
// 把所有属性变成只读
type Readonly<T> = { readonly [K in keyof T]: T[K] };
// 只挑出某些属性
type Pick<T, K extends keyof T> = { [P in K]: T[P] };
// 实战:把 API 返回类型里所有字段变成可选(用于"部分更新"接口)
type PatchPayload<T> = Partial<Pick<T, 'name' | 'desc'>>;价值:基于已有类型批量生成新类型,避免手写重复定义。
⑤ 模板字面量类型
type Event = 'click' | 'focus';
type Handler = `on${Capitalize<Event>}`; // 'onClick' | 'onFocus'
type Path = `/api/${'user' | 'device'}`; // '/api/user' | '/api/device'价值:字符串模式也能有类型约束(如路由、事件名)。
四件套的关系
keyof / typeof → 从"值/类型"提取信息(输入)
条件类型 → 让类型"会判断"(控制流)
infer → 在判断时"捕获"类型(提取)
映射类型 → 批量变换(输出)三、Dart 没有类型体操,它用什么?
理解了 Dart「运行时类型真实」的定位,答案就很自然:TS 在编译期用类型系统算出来,Dart 在运行时用语言特性表达出来。
| TS 的做法 | Dart 的对应 | 机制差异 |
|---|---|---|
判别联合 type R = A | B | sealed class + switch 表达式 | TS 靠 kind 字段判别;Dart 靠运行时类型,sealed 强制穷尽 |
| 映射类型做不可变数据 | @freezed 代码生成 | TS 靠类型运算;Dart 靠生成 copyWith / 相等性 |
泛型上界 T extends U | 同样支持 | 两边都有 |
| 协变 | 同样支持,Dart 需显式 out | 两边都有 |
本节只留对照地图,不重复细节——Dart 侧
sealed class与@freezed的完整写法、 项目真实用法与配套练习,在 Flutter 01 篇 · Dart 语言基础 的第七、八节 (那才是学习侧,讲得更完整)。本节保留的,是两边为什么会长成这样这一层。
一个最小示例(感受差异即可,不展开):
sealed class Result<T> {}
class Ok<T> extends Result<T> { final T value; Ok(this.value); }
class Err<T> extends Result<T> { final String message; Err(this.message); }
int handle(Result<int> r) => switch (r) {
Ok(value: final v) => v * 2,
Err() => 0,
};sealed 的关键价值:加了它之后所有子类必须在同一个文件里,编译器才知道「总共就这几个」, 从而能做穷尽性检查——switch 少写一个分支就编译报错。这正是本项目 lib/domain/models/result.dart 的做法。
四、类型体操该学到什么程度?(本篇最重要的判断)
4.1 现实:80% 的业务代码不需要类型体操
够用的程度(建议掌握到这一层):
| 能力 | 为什么够用 |
|---|---|
泛型 + 约束(T extends X) | 日常 90% 场景 |
判别联合(kind 字段) | 处理多状态(加载/成功/失败) |
keyof / typeof | 避免重复定义 |
内置工具类型(Partial / Pick / Omit / Record) | 覆盖大部分变换需求 |
这就够了。 下面这些不建议在日常业务里用:
| 高级特性 | 为什么慎用 |
|---|---|
| 递归条件类型 | 编译慢、报错难懂 |
多层嵌套 infer | 可读性极差,别人看不懂 |
| 复杂的模板字面量类型拼装 | 调试成本高 |
4.2 什么时候值得写类型体操
| 场景 | 例子 |
|---|---|
| 写库 / 框架 | 用户会传入各种类型,需要精确推导返回值 |
| SDK / API 客户端 | 希望 URL → 响应类型自动对应 |
| 配置驱动的代码 | 从配置对象自动推导可用字段 |
| 消除大量重复定义 | 手写要 200 行,类型运算 20 行 |
判断标准:
写一次,受益多次(库/基础设施)→ 值得 写一次,只用一次(业务代码)→ 不值得
4.3 类型体操的三个代价
| 代价 | 说明 |
|---|---|
| 编译变慢 | 复杂类型运算会显著拖慢 TS 编译,大项目尤其明显 |
| 报错信息难懂 | 报错可能是一长串类型展开,新人直接劝退 |
| 维护成本高 | 写的人半年后自己都看不懂 |
4.4 一条实用原则
能用内置工具类型解决的,不要自己造。能显式写清楚的,不要用类型运算推导。
// ❌ 过度:套了三层才搞出一个"去掉 id 的 User"
type UserWithoutId = Omit<User, 'id'>;
// 其实 Omit 就够了,不需要自己写条件类型
// ❌ 过度:为了"优雅"把简单类型写成运算
type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
// ⚠️ 这个能跑,但报错信息会非常难懂,且拖慢编译五、自检清单
学完本文,你应该能够:
- [ ] 说清 TS 与 Dart 类型系统的根本差异(擦除 vs 具体化)
- [ ] 解释为什么 Dart 生态重度依赖代码生成
- [ ] 解释为什么
list instanceof Array<number>在 TS 里是语法错误 - [ ] 说出 Dart 的
list is List<int>为什么能工作 - [ ] 使用
keyof/typeof从值提取类型 - [ ] 写出条件类型,并解释
infer的作用 - [ ] 用映射类型批量变换(
Partial/Pick/Omit) - [ ] 用判别联合处理多状态(并说出这是为弥补类型擦除)
- [ ] 用
sealed class+ 模式匹配写出 Dart 版的联合类型 - [ ] 解释
sealed为什么能做穷尽性检查(子类必须在同一文件) - [ ] 对照写出"API 响应类型"的 TS 版与 Dart 版
- [ ] 判断什么时候该用、不该用类型体操(各 2 条)
- [ ] 说出类型体操的三个代价
六、常见误区
| 误区 | 正解 |
|---|---|
| "类型体操写得越复杂越厉害" | ❌ 可读性 > 炫技。能显式写清就别用运算 |
| "Dart 也能做类型体操" | ❌ Dart 编译期无条件类型/映射类型/模板字面量类型 |
| "TS 泛型运行时还在" | ❌ 完全擦除,运行时只剩 Array/Object |
| "Dart 的 sealed 和 TS 的联合类型完全一样" | ⚠️ 目的相似,机制不同:TS 靠类型运算,Dart 靠运行时类继承 |
| "用了类型体操就不用写运行时校验了" | ❌ 类型只在编译期。外部数据(API 返回)仍需运行时校验 |
| "Freezed 只是为了少写代码" | ⚠️ 更主要是提供 copyWith / == / 联合类型——这些 Dart 语言本身没有 |
| "所有项目都该上严格类型体操" | ❌ 业务代码显式写清更可维护;库和基础设施才值得 |
| "类型写对了,运行时就不会出错" | ❌ 类型只保证编译期。API 数据、用户输入都可能不符合类型 |
| "Dart 的 covariant 和 TS 的协变一样" | ⚠️ 概念相关但机制不同;日常不必深究,遇到再查 |
七、动手练习
练习 A:用 keyof 消除重复定义
有一个配置对象,写一个 getConfig<K extends keyof Config>(key: K): Config[K], 验证:传不存在的 key 是否编译报错。
练习 B:判别联合 vs sealed class(推荐)
同一个需求写两遍——"加载中 / 加载成功 / 加载失败"三态:
- 用 TS 判别联合写一遍
- 用 Dart
sealed class+switch表达式写一遍
对比观察:
- 哪边的穷尽性检查更强?(提示:
sealed强制子类同文件) - 哪边运行时能看到具体状态?(提示:类型擦除)
- 如果漏写一个分支,两边各会怎样?
练习 C:类型体操的"度"(思考题)
下面这个类型,你会用在业务代码里吗?说明你的判断依据:
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};思考:它解决了什么问题?代价是什么?有没有更简单的替代?
💡 提示:参考 4.2 的"写一次,受益多次"标准。
相关文档
本区
- frontend README —— 前端沉淀区索引
- 01 · 跨端方案横向对比 —— 语言与生态层面的对比
- 02 · 前端工程化 —— 构建与工程化
Flutter 区(双向对照)
- Flutter 01 篇 · Dart 语言基础 —— TS → Dart 基础语法对照(本篇的前置)
- Flutter 06 篇 · Dart 异步并发与进阶语法 —— Dart 进阶(含 sealed / 模式匹配)
- Flutter 08 篇 · 状态管理 Riverpod —— Freezed 与状态联合类型
- Flutter 09 篇 · 数据层链路 ——
Result<T>的真实用法
本篇的定位:TS 类型体操是本体(第二、四节),Dart 对照只给地图不给细节—— 细节在学习侧(上面 Flutter 区各篇)。想理解「为什么两边会长成这样」,看第一节:类型擦除 vs 类型具体化。