TypeScript 在项目中的实际解决的问题

核心理念

  • 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 返回值

resultZod 生成的结果对象,不是后端数据:

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 的执行逻辑:

  1. T 去匹配 Promise<?> 这个模式
  2. 如果匹配成功,把 ? 位置的类型赋值给 U
  3. 然后 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

执行过程:

  1. T extends Promise<infer U> --- T 是不是 Promise<某个类型>
  2. infer U --- 如果是,把那个类型捕获出来叫 U
  3. ? 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 为例):

  1. T = 'GET /api/user/:id'
  2. 模式 ${infer M} ${string} 表示:M + 空格 + 任意字符串
  3. TS 尝试匹配,发现 M = 'GET',后面是 ' /api/user/:id'
  4. 匹配成功,返回 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,不会丢失

需求

  • 传入初始值,自动推导表单字段类型
  • valueserrorsonChange 都要类型安全
  • 字段名写错要报错,值类型不对要报错

没有类型推导的痛苦

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

  1. TS 看到第一个参数是 'age'
  2. 推断出 K = 'age'
  3. 计算 T[K] = T['age'] = number
  4. 所以第二个参数 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>anyobject)作为参数类型时,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 约束有两个作用:

  1. 限制调用者:只能传入符合约束的类型,传错了编译报错
  2. 保障实现者 :函数内部可以安全地假设 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 显示提示/报错/补全

工作机制

  1. VSCode 内置 TS 扩展,启动时自动运行 tsserver
  2. 每次敲键盘,VSCode 通过 LSP 协议把代码发给 tsserver
  3. tsserver 实时分析:解析 AST → 运行类型推导 → 返回结果
  4. 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 TT[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 类型统一,避免重复定义

两者互补,覆盖了编译时和运行时的类型安全。

相关推荐
Sun_小杰杰哇20 小时前
Dayjs常用操作使用
开发语言·前端·javascript·typescript·vue·reactjs·anti-design-vue
晴殇i21 小时前
package.json 中的 dependencies 与 devDependencies:深度解析
前端·设计模式·前端框架
pas13621 小时前
25-mini-vue fragment & Text
前端·javascript·vue.js
何贤21 小时前
2025 年终回顾:25 岁,从“混吃等死”到别人眼中的“技术专家”
前端·程序员·年终总结
冴羽21 小时前
CSS 新特性!瀑布流布局的终极解决方案
前端·javascript·css
满天星辰21 小时前
Vue 响应式原理深度解析
前端·vue.js
怪可爱的地球人21 小时前
em,rem,px,rpx单位换算,你弄懂了吗?
前端
码途潇潇21 小时前
JavaScript有哪些数据类型?如何判断一个变量的数据类型?
前端·javascript
满天星辰21 小时前
Vue真的是单向数据流?
前端·vue.js