跳到主要内容

接口返回的 JSON,Dart 与 TypeScript 怎么给它"上类型"

从后端接口拿到的 JSON 天生没有类型系统,只有 null / bool / number / string / array / object 六种形态。真正要解决的是三件事:

  1. parse 完的那个"无类型值"(TS 的 any、Dart 的 dynamic)怎么进入你的类型世界;
  2. 怎么声明出响应对象的形状;
  3. 运行时数据不听话(缺字段、类型不对、多态没命中)时,谁来兜底/报错

全文一条主线,后面所有"深浅台阶"都是它的展开:

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

anydynamic 的共同点:让编译器闭嘴,成员随便访问。不同点是后续:

  • 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 就抛 _TypeErrorfail fast 发生在边界上——数据刚进来就被打回,而不是被带进业务代码深处再爆。

为什么有这种差异:TS 的 as 是类型断言(erased);Dart 的 as 是运行时 cast(带检查、会抛)。因此"声明形状"这件事,Dart 里天然自带一道运行时卡口,TS 则要把校验额外补上(要么手写,要么上 schema 库)。

2. 中段一:null / 可选 / 默认值,处理"缺键 / 显式 null / 有值"

JSON 里一个字段有三种可能:缺键、显式 null、有值。接口文档常写"昵称可能没有"。

Dart 空安全下的惯用写法:

nick: json['nickname'] as String? ?? '匿名'
  • 缺键时 map['nickname'] 返回 nullas 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
缺键nullundefined
显式 nullnullnull
有值

两边的 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 里都是字符串或数字,语言帮不了多少,两边都靠自己约。

TypeScriptDart
数字只有 number(double);大整数会被 IEEE754 吃掉,约定改走 stringint / double 分开;as num 后按需转
日期就是 stringnew Date(...) 不校验格式DateTime.tryParse,可在 fromJson 里用它校验/转换
枚举字符串字面量联合/enum 是编译期;外部字符串要手写 map 或 zod .enumenum 有 .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 在边界抛),样板重
codegenjson_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. 收个尾:能抄走的心法

  1. parse 出来的无类型值,要在最外层尽快收口成强类型,别让 any/dynamic 流进业务代码——一旦流进去,之后每一层的类型都是表演。
  2. "声明形状"和"校验形状"是两件事:Dart 默认从 fromJson 拿到部分运行时校验(as 会抛),TS 默认什么都没有——所以 TS 处理外部 JSON 要么手写守卫,要么上 schema 库,不要只靠 as
  3. 复杂形状(联合 / 泛型 / 递归)两边都能表达,但"运行时那一半"都要显式写或交给生成器:判别字段分派、泛型如何构造 T、递归校验、非法日期/枚举,这些不会因为类型声明好看了就自动成立。
  4. 团队里定一条规则比语法更重要:外部数据只在边界校验一次,之后全当可信类型用。校验放太深则到处兜底,放太浅则形同虚设——这跟语言无关。