核心理念
- TypeScript 类型推断:把运行时错误变成编译时错误,同时为编辑器提供智能提示(自动补全、悬浮类型信息)
- 泛型的核心价值:让类型信息在函数调用过程中"流动",而不是被抹掉
一、为什么有了 TypeScript 还需要 Zod?
本质区别
| TypeScript | Zod | |
|---|---|---|
| 作用时机 | 编译时 | 运行时 |
| 编译后 | 类型信息完全擦除 | 验证逻辑保留 |
| 保护范围 | 你写的代码 | 外部进来的数据 |
TypeScript 的局限
typescript
interface User {
name: string;
age: number;
}
// 编译后类型信息完全消失
const user: User = JSON.parse(apiResponse); // 运行时没有任何验证!
如果 API 返回 { name: 123, age: "abc" },TypeScript 不会报错,程序会带着错误数据继续运行。
Zod 的价值
typescript
import { z } from 'zod';
const UserSchema = z.object({
name: z.string(),
age: z.number(),
});
const user = UserSchema.parse(JSON.parse(apiResponse));
// 数据不符合 schema 会直接抛错
使用场景判断
| 数据来源 | 用什么 |
|---|---|
| 外部进来的(API、用户输入、env、localStorage) | Zod |
| 内部流转的(props、state、函数签名) | TS 类型 |
二、Zod 的核心优势:Schema 即类型
z.infer 自动导出类型
typescript
// 只写一次 schema
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().min(0),
});
// 自动导出 TypeScript 类型,不用手写 interface
type User = z.infer<typeof UserSchema>;
// 等价于:type User = { name: string; email: string; age: number }
对比传统方式
typescript
// ❌ 传统方式:写两遍,容易不同步
interface User {
name: string;
email: string;
age: number;
}
function validateUser(data: unknown): User {
// 手写验证逻辑...
}
// ✅ Zod:Single Source of Truth
const UserSchema = z.object({...});
type User = z.infer<typeof UserSchema>;
const user = UserSchema.parse(data); // 验证 + 类型推导一步到位
好处
- 改一处,处处生效 --- 修改 schema,类型自动更新
- IDE 全程有提示 --- parse 后的数据有完整类型支持
- 复杂类型也能推导 --- union、optional、transform 等都能正确推导
三、Zod 验证失败的处理
方式一:parse - 直接抛错
typescript
try {
const user = UserSchema.parse(apiResponse);
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error.errors);
// [{ path: ['email'], message: 'Invalid email' }]
}
}
方式二:safeParse - 不抛错(推荐)
typescript
const result = UserSchema.safeParse(apiResponse);
if (result.success) {
console.log(result.data.name); // result.data 类型安全
} else {
console.log(result.error.flatten()); // 详细错误信息
}
理解 safeParse 返回值
result 是 Zod 生成的结果对象,不是后端数据:
typescript
// 验证成功时
{ success: true, data: { name: "张三", age: 25 } }
// 验证失败时
{ success: false, error: ZodError }
实际项目处理模式
typescript
async function fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`);
const json = await res.json();
const result = UserSchema.safeParse(json);
if (!result.success) {
reportError('API schema mismatch', result.error); // 上报监控
return null; // 或返回默认值、抛业务异常
}
return result.data;
}
四、TypeScript 高级类型系统
TypeScript 的类型系统本质上是一门图灵完备的函数式编程语言
核心价值:通过类型推断,把运行时错误变成编译时错误
泛型:类型层面的"函数"
泛型的核心价值:让类型信息在函数调用过程中"流动",而不是被抹掉
typescript
type Wrapper<T> = { value: T; timestamp: number };
type WrappedString = Wrapper<string>; // { value: string; timestamp: number }
条件类型:类型层面的 if-else
typescript
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<123>; // false
infer:类型层面的模式匹配
infer 的作用:"这个位置的类型我不知道,但帮我捕获出来,后面我要用"
类比正则表达式的捕获组:
javascript
正则:/Hello (\w+)/ → 捕获括号里匹配到的内容
类型:T extends Promise<infer U> → 捕获 Promise 里面的类型
为什么 infer 只能和 extends 一起用?
infer 需要一个匹配上下文 才能知道要捕获什么,extends 提供了这个上下文:
typescript
T extends Promise<infer U> ? U : never
// ↑ 这是一个"模式"
// ↑ 在这个模式里,U 是要捕获的部分
TS 的执行逻辑:
- 拿
T去匹配Promise<?>这个模式 - 如果匹配成功,把
?位置的类型赋值给U - 然后
U就可以在?后面使用了
typescript
// ❌ 不能单独使用 infer
type Bad<T> = infer U; // 错误:infer 只能在条件类型的 extends 子句中使用
// 因为 TS 不知道 U 应该从哪里推断,没有模式匹配就没有"捕获"的来源
就像正则表达式的捕获组必须在匹配模式里,不能单独写 (\w+)。
示例:提取 Promise 内部类型
typescript
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type A = Unwrap<Promise<string>>; // string
type B = Unwrap<number>; // number
执行过程:
T extends Promise<infer U>--- T 是不是Promise<某个类型>?infer U--- 如果是,把那个类型捕获出来叫U? U : T--- 条件成立返回U,否则返回T
示例:提取函数返回值类型
typescript
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type Fn = (x: number) => { name: string; age: number };
type Result = ReturnType<Fn>; // { name: string; age: number }
示例:infer 配合模板字面量提取字符串
infer 不仅能提取泛型参数,还能从字符串字面量类型中提取部分内容:
typescript
// 从 'GET /api/user/:id' 中提取方法
type ExtractMethod<T> = T extends `${infer M} ${string}` ? M : never;
type Method = ExtractMethod<'GET /api/user/:id'>; // 'GET'
type Method2 = ExtractMethod<'POST /api/login'>; // 'POST'
// 从 'GET /api/user/:id' 中提取路径
type ExtractPath<T> = T extends `${string} ${infer P}` ? P : never;
type Path = ExtractPath<'GET /api/user/:id'>; // '/api/user/:id'
执行过程(以 ExtractMethod 为例):
T = 'GET /api/user/:id'- 模式
${infer M} ${string}表示:M+ 空格 + 任意字符串 - TS 尝试匹配,发现
M = 'GET',后面是' /api/user/:id' - 匹配成功,返回
M,即'GET'
这在构建类型安全的路由、API 客户端时非常有用。
映射类型:批量变换对象类型
typescript
type Partial<T> = { [K in keyof T]?: T[K] };
type Readonly<T> = { readonly [K in keyof T]: T[K] };
模板字面量类型
typescript
type EventName<T extends string> = `on${Capitalize<T>}`;
type ClickEvent = EventName<'click'>; // "onClick"
五、实战案例:类型安全的表单 Hook
本案例完整展示两个核心理念:
- 类型推断把运行时错误变编译时错误:字段名拼错、值类型不对,编译时就报错
- 泛型让类型"流动" :初始值的类型信息一路流动到
setField,不会丢失
需求
- 传入初始值,自动推导表单字段类型
values、errors、onChange都要类型安全- 字段名写错要报错,值类型不对要报错
没有类型推导的痛苦
typescript
// ❌ 手动指定类型,写两遍,容易不同步
interface FormValues {
username: string;
age: number;
email: string;
}
const { values, setField } = useForm<FormValues>({
username: '',
age: 0,
email: ''
});
setField('usernmae', 'test'); // 拼写错误,运行时才发现
setField('age', '18'); // 类型错了,string 给了 number 字段
实现
typescript
function useForm<T extends Record<string, unknown>>(initialValues: T) {
const [values, setValues] = useState<T>(initialValues);
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
// K extends keyof T:字段名必须是 T 的 key
// T[K]:值类型自动跟着字段走
function setField<K extends keyof T>(field: K, value: T[K]) {
setValues(prev => ({ ...prev, [field]: value }));
}
function setError<K extends keyof T>(field: K, message: string) {
setErrors(prev => ({ ...prev, [field]: message }));
}
return { values, errors, setField, setError };
}
使用效果
typescript
const { values, setField } = useForm({
username: '',
age: 0,
isAdmin: false,
});
setField('username', 'john'); // ✅
setField('age', 25); // ✅
setField('usernmae', 'john'); // ❌ 编译错误:字段名不存在
setField('age', '25'); // ❌ 编译错误:期望 number,给了 string
编辑器智能提示
类型推断不仅能报错,还能提供智能提示:
typescript
setField('|') // 光标在这里时,编辑器自动弹出补全列表:
// - username
// - age
// - isAdmin
values.| // 光标在这里时,编辑器自动提示所有可用字段和类型:
// - username: string
// - age: number
// - isAdmin: boolean
这是因为 TS 推断出了具体类型,编辑器(通过 TypeScript Language Server)就能提供精确的补全和类型信息。如果类型信息丢失(比如变成 Record<string, unknown>),这些提示就没了。
类型推断的完整执行过程
第一步:调用 useForm,推断泛型 T
typescript
const { values, setField } = useForm({
username: '',
age: 0,
isAdmin: false,
});
TS 看到你传入的对象字面量,自动推断出泛型 T 的具体类型:
typescript
T = {
username: string;
age: number;
isAdmin: boolean;
}
此时函数签名相当于:
typescript
function useForm(initialValues: { username: string; age: number; isAdmin: boolean })
第二步:keyof T 生成字段名联合类型
typescript
keyof T = 'username' | 'age' | 'isAdmin'
这意味着所有用到 keyof T 的地方,只能是这三个字符串之一。
第三步:setField 调用时的推断
typescript
function setField<K extends keyof T>(field: K, value: T[K])
当你调用 setField('age', 25) 时:
- TS 看到第一个参数是
'age' - 推断出
K = 'age' - 计算
T[K]=T['age']=number - 所以第二个参数
value必须是number
typescript
setField('age', 25); // ✅ K='age', T[K]=number, 25 是 number
setField('age', '25'); // ❌ K='age', T[K]=number, '25' 是 string,类型不匹配
setField('username', 'john'); // ✅ K='username', T[K]=string
setField('isAdmin', true); // ✅ K='isAdmin', T[K]=boolean
第四步:错误字段名的拦截
typescript
setField('usernmae', 'john'); // ❌ 编译错误
TS 检查 'usernmae' 是否属于 keyof T(即 'username' | 'age' | 'isAdmin'),发现不属于,直接报错。
完整推断流程图
php
useForm({ username: '', age: 0, isAdmin: false })
│
▼
T 被推断为 { username: string; age: number; isAdmin: boolean }
│
▼
keyof T = 'username' | 'age' | 'isAdmin'
│
▼
setField('age', 25) 调用时:
│
├─ K 被推断为 'age'(因为第一个参数是 'age')
│
├─ T[K] = T['age'] = number
│
└─ 检查 25 是否符合 number ✅
为什么这样设计有效
关键在于 泛型 K 是在调用时才确定的:
typescript
function setField<K extends keyof T>(field: K, value: T[K])
// ↑ 每次调用都会推断出具体的 K
- 调用
setField('age', ...)时,K = 'age' - 调用
setField('username', ...)时,K = 'username'
然后 T[K] 会根据 K 的值动态计算出对应的类型,实现了"字段名和值类型的绑定"。
深入理解:泛型变量与 extends 约束
泛型变量的本质
泛型变量 T 是一个类型占位符 ,它的具体值由调用时传入的参数反推确定:
typescript
function useForm<T extends Record<string, unknown>>(initialValues: T)
// ↑ T 是占位符,调用时才知道具体是什么类型
typescript
// 调用时,TS 根据参数反推 T 的值
useForm({ username: '', age: 0 })
// └─ 参数类型是 { username: string; age: number }
// 所以 T = { username: string; age: number }
extends 约束的作用
T extends Record<string, unknown> 的意思是:T 可以是任何类型,但必须满足 Record<string, unknown> 的约束。
Record<string, unknown> 表示"key 是 string,value 是任意类型的对象",本质上就是普通对象。
如果传入不符合约束的类型会怎样?
typescript
// ✅ 符合约束:普通对象
useForm({ username: '', age: 0 });
// ❌ 不符合约束:数组不是 Record<string, unknown>
useForm([1, 2, 3]);
// 编译错误:Argument of type 'number[]' is not assignable to parameter of type 'Record<string, unknown>'
// ❌ 不符合约束:原始类型
useForm('hello');
// 编译错误:Argument of type 'string' is not assignable to parameter of type 'Record<string, unknown>'
// ❌ 不符合约束:null
useForm(null);
// 编译错误
extends 约束 vs 直接指定类型
typescript
// 方式一:直接指定参数类型(不灵活)
function useForm(initialValues: Record<string, unknown>)
// 方式二:泛型 + extends 约束(灵活且安全)
function useForm<T extends Record<string, unknown>>(initialValues: T)
方式一的问题:类型信息丢失
typescript
// 直接指定类型的实现
function useFormBad(initialValues: Record<string, unknown>) {
const [values, setValues] = useState(initialValues);
return { values };
}
const { values } = useFormBad({ username: '', age: 0 });
// values 的类型是 Record<string, unknown>
// TS 只知道它是"某个对象",不知道具体有哪些字段
values.username; // 类型是 unknown,不是 string
values.age; // 类型是 unknown,不是 number
values.foo; // ✅ 不报错!TS 认为任何 string key 都可能存在
// 使用时必须手动断言,很麻烦且不安全
const name = values.username as string;
方式二的优势:保留具体类型
typescript
// 泛型 + extends 约束的实现
function useFormGood<T extends Record<string, unknown>>(initialValues: T) {
const [values, setValues] = useState<T>(initialValues);
return { values };
}
const { values } = useFormGood({ username: '', age: 0 });
// values 的类型是 { username: string; age: number }
// TS 知道具体有哪些字段,每个字段是什么类型
values.username; // 类型是 string ✅
values.age; // 类型是 number ✅
values.foo; // ❌ 编译错误:Property 'foo' does not exist
对比总结
| 直接指定类型 | 泛型 + extends | |
|---|---|---|
| 参数类型 | Record<string, unknown> |
T(具体类型) |
| 返回值类型 | Record<string, unknown> |
T(具体类型) |
| 字段访问 | 任意字段都不报错 | 只能访问存在的字段 |
| 字段类型 | 全是 unknown |
保留原始类型 |
| IDE 提示 | 无 | 有完整的字段补全 |
泛型的核心价值就是:让类型信息在函数调用过程中"流动",而不是被抹掉。
深入理解"类型流动"
什么是类型被"抹掉"?
当你用宽泛的类型(如 Record<string, unknown>、any、object)作为参数类型时,TS 只能记住这个宽泛的类型,具体的类型信息就丢失了:
typescript
function identity(value: object): object {
return value;
}
const user = identity({ name: 'john', age: 25 });
// user 的类型是 object
// TS 已经"忘记"了它原本是 { name: string; age: number }
类型信息在进入函数时被"抹掉"了,出来后就只剩下 object。
什么是类型"流动"?
泛型让 TS 把具体类型"带着走",从输入流动到输出:
typescript
function identity<T>(value: T): T {
return value;
}
const user = identity({ name: 'john', age: 25 });
// user 的类型是 { name: string; age: number }
// TS "记住"了具体类型,并让它流动到返回值
流动过程可视化
css
没有泛型(类型被抹掉):
{ name: string; age: number } ──进入函数──► object ──返回──► object
↑
类型信息在这里丢失
有泛型(类型流动):
{ name: string; age: number } ──进入函数──► T ──返回──► { name: string; age: number }
↑ ↑
T 记住了具体类型 原样流出
在 useForm 中的体现
typescript
// 类型流动的完整链路:
useForm({ username: '', age: 0 })
│
▼ 输入类型 { username: string; age: number }
│
▼ T 捕获这个类型
│
▼ useState<T> 使用这个类型
│
▼ setField<K extends keyof T> 使用这个类型
│
▼ 返回的 values 保持这个类型
│
▼ 调用方拿到的 values 类型是 { username: string; age: number }
整个过程中,{ username: string; age: number } 这个具体类型被 T 捕获后,一路流动到最终的返回值,没有任何地方丢失。
为什么"流动"很重要?
因为类型的价值在于使用时。如果类型在函数调用后丢失了,后续代码就无法获得类型检查和 IDE 提示:
typescript
// 类型丢失后,后续代码"裸奔"
const { values } = useFormBad({ username: '', age: 0 });
values.usernam = 'test'; // 拼写错误,不报错
values.age = '25'; // 类型错误,不报错
// 类型流动后,后续代码受保护
const { values } = useFormGood({ username: '', age: 0 });
values.usernam = 'test'; // ❌ 编译错误:属性不存在
values.age = '25'; // ❌ 编译错误:类型不匹配
一句话总结:泛型就像一个"类型变量",它在函数入口捕获具体类型,然后把这个类型带到函数内部和出口,让整个调用链路都能享受到类型安全。
约束的意义
extends 约束有两个作用:
- 限制调用者:只能传入符合约束的类型,传错了编译报错
- 保障实现者 :函数内部可以安全地假设 T 是对象类型,使用
keyof T等操作
typescript
function useForm<T extends Record<string, unknown>>(initialValues: T) {
// 因为 T extends Record<string, unknown>
// 所以 keyof T 一定是 string(对象的 key)
// 这里的操作是类型安全的
const keys: (keyof T)[] = Object.keys(initialValues) as (keyof T)[];
}
总结
| 概念 | 说明 |
|---|---|
泛型变量 T |
类型占位符,调用时由参数反推确定 |
extends 约束 |
限制 T 必须满足某种结构,否则编译报错 |
| 反推机制 | TS 根据实际传入的参数,自动推断出 T 的具体类型 |
| 约束 + 反推 | 既保证类型安全(约束),又保留具体类型信息(反推) |
六、VSCode 如何获得类型提示
核心:TypeScript Language Server
arduino
你写代码 → VSCode → TypeScript Language Server → 类型推导引擎
↓
返回类型信息、错误、补全建议
↓
VSCode 显示提示/报错/补全
工作机制
- VSCode 内置 TS 扩展,启动时自动运行
tsserver - 每次敲键盘,VSCode 通过 LSP 协议把代码发给 tsserver
- tsserver 实时分析:解析 AST → 运行类型推导 → 返回结果
- VSCode 渲染:红色波浪线、悬浮提示、自动补全列表
功能对应
| 功能 | 背后在做什么 |
|---|---|
| 悬浮显示类型 | tsserver 推导出类型返回给 VSCode |
| 自动补全 | tsserver 返回当前上下文可用的属性/方法 |
| 红色波浪线 | tsserver 检测到类型错误 |
| F12 跳转定义 | tsserver 找到符号定义位置 |
类型推导的"智能"全在 TypeScript 编译器里,VSCode 只是显示层。
总结
| 概念 | 作用 |
|---|---|
| TypeScript 类型 | 编译时检查你写的代码 |
| Zod | 运行时验证外部数据 + Schema 自动导出类型 |
z.infer |
Schema 和类型统一,Single Source of Truth |
| 泛型 | 让类型可复用,让类型信息"流动" |
| 条件类型 | 根据条件返回不同类型 |
infer |
从复杂类型中提取部分 |
| LSP | 编辑器获得类型提示的桥梁 |
核心理念
TypeScript 类型推断:把运行时错误变成编译时错误。通过泛型、条件类型等机制,让类型信息在代码中"流动",在编译阶段就能发现字段拼写错误、类型不匹配等问题。
以 setField 为例:
typescript
// ❌ 没有类型推断的写法
function setFieldBad(field: string, value: unknown) {
// ...
}
setFieldBad('usernmae', 'john'); // 拼写错误,编译不报错,运行时字段没更新
setFieldBad('age', '25'); // 类型错误,编译不报错,运行时逻辑出问题
// ✅ 有类型推断的写法
function setField<K extends keyof T>(field: K, value: T[K]) {
// ...
}
const { setField } = useForm({ username: '', age: 0 });
setField('usernmae', 'john'); // ❌ 编译错误:'usernmae' 不在 'username' | 'age' 中
setField('age', '25'); // ❌ 编译错误:类型 'string' 不能赋值给类型 'number'
setField('age', 25); // ✅ 编译通过
关键在于 K extends keyof T 和 T[K] 的配合:
- TS 根据你传入的初始值,推断出
T = { username: string; age: number } - 调用
setField('age', ...)时,推断出K = 'age',进而推断出value必须是number - 字段名和值类型的对应关系,在编译时就被锁死了
这就是类型推断的威力:不是简单的类型标注,而是让 TS 根据上下文自动推导出约束关系,把本该运行时才暴露的 bug 提前到编译时拦截。
Zod 的核心价值:Schema 即类型(Single Source of Truth)。
- 传统方式:手写 interface + 手写验证逻辑,两份代码容易不同步
- Zod 方式:只写一次 Schema,通过
z.infer自动导出 TypeScript 类型
typescript
// 一处定义,两处受益
const UserSchema = z.object({ name: z.string(), age: z.number() });
type User = z.infer<typeof UserSchema>; // 编译时类型
UserSchema.parse(data); // 运行时验证
两者的关系:
- TypeScript 保证你写的代码类型正确(编译时)
- Zod 保证外部数据符合预期(运行时)
z.infer让 Zod Schema 和 TS 类型统一,避免重复定义
两者互补,覆盖了编译时和运行时的类型安全。