Skip to content

03 · TypeScript 进阶:类型体操与 Dart 对照 ​

本篇解决什么问题: 你在 Flutter 里用 Dart,但类型系统的心智模型还是 TS 的。 本篇帮你把两边的类型能力对照打通,并回答一个关键问题: 类型体操到底该学到什么程度?

📌 与 01 篇的分工: Flutter 01 篇 是 TS → Dart 的基础语法对照(变量、空安全、异步); 本篇是类型系统层面的进阶对照(泛型、条件类型、模式匹配),并反过来用 Dart 加深你对 TS 的理解。

⚠️ 易变信息:TS 版本特性(如 satisfies、const 类型参数)演进较快, 按本库约定以官方文档为准;本篇讲稳定的类型系统原理与判断框架。


开篇:类型系统对照(先建立地图) ​

能力TypeScriptDart
泛型✅ 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) ​

ts
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):

ts
// 用一个"类型标签"字段在运行时区分
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) ​

dart
final list = <int>[1, 2, 3];

print(list is List<int>);    // ✅ true(运行时真能判断)
print(list is List<String>); // ✅ false
print(list is List);         // true

Dart 的泛型在运行时是真实存在的,所以:

能力DartTS
x is List<int>✅❌
反射拿到泛型参数✅(dart:mirrors / 部分通过 runtimeType)❌

代价:运行时要携带类型信息,有一点点开销(通常可忽略)。

1.3 这个差异带来的设计取向不同 ​

TypeScriptDart
类型系统定位编译期的"元编程语言"运行时的真实约束
强项表达复杂类型关系(类型体操)运行时类型判断、模式匹配
复杂类型关系怎么做靠类型运算(条件类型、映射类型)靠类继承 + 模式匹配
"从数据结构推导类型"类型体操代码生成(Freezed / json_serializable)

💡 这解释了为什么 Dart 生态重度依赖代码生成(build_runner): 因为 Dart 的编译期类型表达能力弱,copyWith、fromJson、联合类型这些 TS 能用类型体操搞定的事,Dart 只能生成代码。

你在项目里用的 Freezed(见 Flutter 08 篇)就是这个路线的产物。


二、TS 类型体操四件套 ​

先说清楚:这些 Dart 里都没有。学它们是为了写更好的 TS, 顺便理解"为什么 Dart 不需要这些"。

① keyof / typeof:从值提取类型 ​

ts
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 ​

ts
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:在条件类型里"捕获"类型 ​

ts
// 取出数组的元素类型
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;

价值:从已有类型中提取信息,避免重复写类型。

④ 映射类型:批量变换 ​

ts
// 把所有属性变成可选
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'>>;

价值:基于已有类型批量生成新类型,避免手写重复定义。

⑤ 模板字面量类型 ​

ts
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 | Bsealed class + switch 表达式TS 靠 kind 字段判别;Dart 靠运行时类型,sealed 强制穷尽
映射类型做不可变数据@freezed 代码生成TS 靠类型运算;Dart 靠生成 copyWith / 相等性
泛型上界 T extends U同样支持两边都有
协变同样支持,Dart 需显式 out两边都有

本节只留对照地图,不重复细节——Dart 侧 sealed class 与 @freezed 的完整写法、 项目真实用法与配套练习,在 Flutter 01 篇 · Dart 语言基础 的第七、八节 (那才是学习侧,讲得更完整)。本节保留的,是两边为什么会长成这样这一层。

一个最小示例(感受差异即可,不展开):

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 一条实用原则 ​

能用内置工具类型解决的,不要自己造。能显式写清楚的,不要用类型运算推导。

ts
// ❌ 过度:套了三层才搞出一个"去掉 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(推荐) ​

同一个需求写两遍——"加载中 / 加载成功 / 加载失败"三态:

  1. 用 TS 判别联合写一遍
  2. 用 Dart sealed class + switch 表达式写一遍

对比观察:

  • 哪边的穷尽性检查更强?(提示:sealed 强制子类同文件)
  • 哪边运行时能看到具体状态?(提示:类型擦除)
  • 如果漏写一个分支,两边各会怎样?

练习 C:类型体操的"度"(思考题) ​

下面这个类型,你会用在业务代码里吗?说明你的判断依据:

ts
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

思考:它解决了什么问题?代价是什么?有没有更简单的替代?

💡 提示:参考 4.2 的"写一次,受益多次"标准。


相关文档 ​

本区 ​

Flutter 区(双向对照) ​

本篇的定位:TS 类型体操是本体(第二、四节),Dart 对照只给地图不给细节—— 细节在学习侧(上面 Flutter 区各篇)。想理解「为什么两边会长成这样」,看第一节:类型擦除 vs 类型具体化。