接口返回的 JSON,Dart 与 TypeScript 怎么给它"上类型"
从后端接口拿到的 JSON 天生没有类型系统,只有 null / bool / number / string / array / object 六种形态。真正要解决的是三件事:
- parse 完的那个"无类型值"(TS 的
any、Dart 的dynamic)怎么进入你的类型世界; - 怎么声明出响应对象的形状;
- 运行时数据不听话(缺字段、类型不对、多态没命中)时,谁来兜底/报错。
全文一条主线,后面所有"深浅台阶"都是它的展开:
TypeScript 的类型在编译后就被擦掉了,运行时查无此"型";Dart 的类型系统在运行时还活着,
as是一次真实的检查。 所以 TS 处理外部 JSON 的默认姿势是"声明一个形状然后假装它成立",Dart 的默认姿势是"在 decode 那一刻逐字段验证,错了就抛"。
先声明:标题里的对比是在"处理不可信的外部 JSON"这个前提下进行的。纯类型体操(Partial、泛型约束)两边都有,但那些不解决"数据真的是这样吗"。
0. 浅到不能再浅:parse 完都得到一个"逃逸舱口"
TypeScript:
const raw: any = JSON.parse(s);
// 或者 fetch 一把梭,Response.json() 的类型就是 Promise<any>
const raw2: any = await fetch("/api/user").then((r) => r.json());
Dart:
import 'dart:convert';
final raw = jsonDecode(s); // dynamic
any 和 dynamic 的共同点:让编译器闭嘴,成员随便访问。不同点是后续:
- TS 的
any在运行时根本不存在,访问raw.a.b.c不产生任何检查,炸不炸全看运气; - Dart 的
dynamic成员访问在运行时解析,把dynamic传给一个强类型形参会触发运行时类型检查——这也是为什么 Dart 里"至少还留了张网"。
浅结论:两边第一步都交出类型控制权,区别从下一步开始显现。
1. 给形状:TS 用 as,Dart 用 factory fromJson
TypeScript:声明接口 + as 断言(纯编译期)
interface User {
id: number;
name: string;
}
const u = JSON.parse(s) as User;
as 是一次编译期断言,不产出任何运行时代码。后端要是返回 {"id":"123","name":"x"}(id 是字符串),TS 毫无察觉——u.id 在运行时就是字符串,等你某处调用 u.id.toFixed(2) 才炸,报错点离"数据错了"这个真相已经很远。
Dart:模型类 + 逐字段 as(运行时 cast)
class User {
final int id;
final String name;
User({required this.id, required this.name});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as int, // 这里是真实的运行时类型检查
name: json['name'] as String,
);
}
final u = User.fromJson(jsonDecode(s) as Map<String, dynamic>);
同样喂 {"id":"123"},在 decode 那一刻 json['id'] as int 就抛 _TypeError。fail fast 发生在边界上——数据刚进来就被打回,而不是被带进业务代码深处再爆。
为什么有这种差异:TS 的 as 是类型断言(erased);Dart 的 as 是运行时 cast(带检查、会抛)。因此"声明形状"这件事,Dart 里天然自带一道运行时卡口,TS 则要把校验额外补上(要么手写,要么上 schema 库)。
2. 中段一:null / 可选 / 默认值,处理"缺键 / 显式 null / 有值"
JSON 里一个字段有三种可能:缺键、显式 null、有值。接口文档常写"昵称可能没有"。
Dart 空安全下的惯用写法:
nick: json['nickname'] as String? ?? '匿名'
- 缺键时
map['nickname']返回null,as String?对 null 合法,??再给默认值; - 如果字段在接口里是必须的,直接
as String——缺键或 null 都会立刻抛错,等于编译器帮你盯着"别漏 null 分支"(没有String?的字段,语法上就不允许是 null)。
TypeScript:
interface User {
nickname?: string | null;
}
// 取值时
const nick = u.nickname ?? '匿名';
TS 层面可选与 nullable 是声明出来的,取值靠 ?. / ??。注意一个细微但会咬人的点:
| JSON 情况 | Dart json['k'] | TS obj.k |
|---|---|---|
| 缺键 | null | undefined |
| 显式 null | null | null |
| 有值 | 值 | 值 |
两边的 map/object 对"缺键"给的是不同值(null vs undefined)。跨端对齐时(同一个接口在 Flutter 和 Web 各写一遍解析),Dart 侧想区分"缺键/显式 null"要用 json.containsKey(...);TS 侧想统一干脆 ?? null。这两段通常不在语言教程里,但真做"一套接口两端消费"的工程一定会撞上。
3. 中段二:嵌套与数组——从"断言一层"到"递归解码"
嵌套是第一个让 TS 的 as 撑不住的场景:as 只能把整块当成目标类型,内层字段错在断言时完全不报。
interface Post {
title: string;
author: { id: number; name: string };
tags: string[];
}
const p = raw as Post; // raw.author.id 实际是 "x"?没人知道,不报错
想要运行时保障就得逐层验证——这正是后面 schema 库存在的理由(见第 7 节)。Dart 的 fromJson 则没有"整块 as"这回事,天然逼你逐层递归:
factory Post.fromJson(Map<String, dynamic> j) => Post(
title: j['title'] as String,
author: Author.fromJson(j['author'] as Map<String, dynamic>),
tags: (j['tags'] as List)
.map((e) => e as String) // 逐个校验,eager,decode 时即抛
.toList(),
);
深坑提醒(两边通用):你解码到哪一层,哪一层才有保障。
- TS 若
raw as Post后直接访问p.author.bio,接口没这个字段——除非运行时校验,否则undefined一路带进 UI,直到渲染才暴露; - Dart 若图省事在某字段上不
fromJson而是直接as Map存起来,那个字段的类型就退化成"你以为安全、其实没有校验"。
4. 深一点:联合 / 多态——判别字段决定形状
典型场景:消息列表既有 text 又有 image;请求结果要么 {success:true, data} 要么 {success:false, error}。
TypeScript:编译期收窄是强项,运行时兜底是短板
type Msg =
| { type: 'text'; text: string }
| { type: 'image'; url: string };
function render(m: Msg) {
switch (m.type) {
case 'text': return m.text; // 此处 m 已被收窄
case 'image': return m.url;
}
}
判别联合 + 穷尽 switch 是 TS 编译期的美。但这只约束类型:raw as Msg 后若后端发来 type: 'video',编译照过、运行时不查。要让运行时也认账,得上 zod 的判别联合:
import { z } from 'zod';
const Msg = z.discriminatedUnion('type', [
z.object({ type: z.literal('text'), text: z.string() }),
z.object({ type: z.literal('image'), url: z.string() }),
]);
type MsgT = z.infer<typeof Msg>; // 类型从 schema 推导,声明与校验永不漂移
const m = Msg.parse(raw); // 运行时逐条尝试,全失败抛可读错误
注意 z.infer:类型不再是手写的 interface,而是从校验器推导出来——单一事实源是 schema,类型只是它的投影。
Dart:sealed class + switch 穷尽(Dart 3)
sealed class Msg {}
final class TextMsg extends Msg { final String text; TextMsg(this.text); }
final class ImageMsg extends Msg { final String url; ImageMsg(this.url); }
Msg msgFromJson(Map<String, dynamic> j) => switch (j['type']) {
'text' => TextMsg(j['text'] as String),
'image' => ImageMsg(j['url'] as String),
final other => throw FormatException('unknown type: $other'),
};
对比着看很有味道:TS 帮你省掉运行时分派(编译期就收窄到具体分支),代价是运行时没人兜底;Dart 的 sealed + switch 逼你显式写完所有分支并给默认分支 throw——类型没骗你,但分派逻辑(包括判别字段)得自己写,正好和运行时解码放在同一个函数里。
5. 再深:枚举 / 日期 / 数字——JSON 没有原生类型的字段
这些值在 JSON 里都是字符串或数字,语言帮不了多少,两边都靠自己约。
| 值 | TypeScript | Dart |
|---|---|---|
| 数字 | 只有 number(double);大整数会被 IEEE754 吃掉,约定改走 string | int / double 分开;as num 后按需转 |
| 日期 | 就是 string,new Date(...) 不校验格式 | 有 DateTime.tryParse,可在 fromJson 里用它校验/转换 |
| 枚举 | 字符串字面量联合/enum 是编译期;外部字符串要手写 map 或 zod .enum | enum 有 .name 往返;X.values.byName(s) 解码(非法会抛),或手写 value→枚举映射 |
一句话:类型系统只管"声明的形状",不管"字符串是不是合法日期/枚举名"。两边想有保障都得在解码处显式转换,只是 Dart 的 DateTime.tryParse 这类工具更常被写进 fromJson,而 TS 通常交给 schema 库的 .date() / .enum()。
6. 重点深水区:泛型信封 ApiResponse<T>——泛型反序列化是两边共同的痛
绝大多数接口外面裹一层 {code, msg, data},希望 data 是任意 T。难的不是声明,是运行时"构造 T"。
TypeScript:声明容易,因为类型参数运行时根本不存在
interface Api<T> {
code: number;
msg: string;
data: T;
}
const r = raw as Api<User>; // data 没有任何运行时校验,T 只是注释
zod 是"值",不能凭空泛型,得用一个工厂函数把内层 schema 传进来:
import { z } from 'zod';
function api<T>(dataSchema: z.ZodType<T>) {
return z.object({ code: z.number(), msg: z.string(), data: dataSchema });
}
const UserApi = api(UserSchema); // data: User
type UserApiT = z.infer<typeof UserApi>;
Dart:泛型同样没有运行时肉身,把"怎么构造 T"当参数传进来
class Api<T> {
final int code;
final String msg;
final T data;
Api({required this.code, required this.msg, required this.data});
factory Api.fromJson(
Map<String, dynamic> j,
T Function(Map<String, dynamic>) dataFromJson,
) =>
Api(
code: j['code'] as int,
msg: j['msg'] as String,
data: dataFromJson(j['data'] as Map<String, dynamic>),
);
}
// 用法:把 User.fromJson 这个工厂函数递进来
final r = Api<User>.fromJson(map, User.fromJson);
到这里应该能看出共性了:只要想在运行时把 JSON 泛型地变成 T,两边都得把"如何构造 T"以值的形式传进去——TS 里是 zod schema,Dart 里是 fromJson 工厂函数。类型参数 T 在运行时两边都不存在,区别只是 TS 的 as 让你以为不需要传。
7. 高阶:递归结构(树 / 评论楼 / 目录树)
声明侧两边都能自引用:
interface TreeNode {
label: string;
children: TreeNode[];
}
class TreeNode {
final String label;
final List<TreeNode> children;
TreeNode({required this.label, required this.children});
factory TreeNode.fromJson(Map<String, dynamic> j) => TreeNode(
label: j['label'] as String,
children: (j['children'] as List)
.map((e) => TreeNode.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
运行时侧:树的递归校验天然安全(无环)。要注意的是别把"递归"和"引用图"混为一谈——JSON 里没有指针,不可能表达环,所谓"parent 引用"其实是扁平 id 列表 + 二次 re-link,那是重构逻辑,不是解码逻辑,两边的朴素 fromJson/schema 都写不了,需要两阶段(先建壳再按 id 回填)。
8. 分水岭:要不要上"运行时 schema / 代码生成"
到这一步已经不是语言语法之争,而是工程选型。两边的方案谱系:
TypeScript 路线
| 姿势 | 手段 | 运行时兜底 |
|---|---|---|
| 裸声明 | interface + as | 无(类型是谎言) |
| 中间派 | 手写 is User() 类型守卫 / 'id' in obj | 有,但要自己写且容易漏 |
| schema 库 | zod / valibot / io-ts,z.infer 推导类型 | 有,schema 即单一事实源 |
TS 生态近年的共识是往右走:让 schema 当唯一事实源,类型从它推导,因为 interface + as 在"不可信外部数据"面前基本不设防。
Dart 路线
| 姿势 | 手段 | 运行时兜底 |
|---|---|---|
| 手写 | factory fromJson + as | 有(cast 在边界抛),样板重 |
| codegen | json_serializable 注解 + build_runner 生成 | 有,且与 toJson 一起生成 |
| 更上层 | freezed(sealed/不可变对象)、OpenAPI 生成 client | 有 |
json_serializable 的样子(样板被生成器接管):
import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart';
@JsonSerializable()
class User {
final int id;
final String name;
User({required this.id, required this.name});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
Dart 的默认路径之所以更偏"手写 fromJson / 注解 codegen",而不是"运行时 schema 库",根源还是第 1 节那句:Dart 的 cast 本身就带运行时检查,默认就有兜底,剩下的问题只是样板多,于是用生成器消灭样板;而 TS 默认完全没有运行时类型,于是社区把"校验"做成显式的一层(zod 等)。
从接口文档直接生成时,两侧都用单一事实源(OpenAPI):TS 用 openapi-typescript / orval,Dart 用 openapi 系生成器——"手写映射和接口漂移"这个老问题,交给生成器从规范到代码一次性解决。
9. 收个尾:能抄走的心法
- parse 出来的无类型值,要在最外层尽快收口成强类型,别让
any/dynamic流进业务代码——一旦流进去,之后每一层的类型都是表演。 - "声明形状"和"校验形状"是两件事:Dart 默认从
fromJson拿到部分运行时校验(as会抛),TS 默认什么都没有——所以 TS 处理外部 JSON 要么手写守卫,要么上 schema 库,不要只靠as。 - 复杂形状(联合 / 泛型 / 递归)两边都能表达,但"运行时那一半"都要显式写或交给生成器:判别字段分派、泛型如何构造 T、递归校验、非法日期/枚举,这些不会因为类型声明好看了就自动成立。
- 团队里定一条规则比语法更重要:外部数据只在边界校验一次,之后全当可信类型用。校验放太深则到处兜底,放太浅则形同虚设——这跟语言无关。