类型编程在业务中的实战
类型编程在业务中的实战
写在前面
前面六篇从类型基础讲到了 .d.ts。这一篇回归业务——类型怎么用在业务代码里。
类型编程不是炫技,用得好的话能减少 50% 的 bug。本篇会讲表单类型化、API 请求类型化、状态机类型化,最后实战把公司 200 个表单的传统项目全面类型化。
一、表单类型化
1.1 传统表单代码的问题
// 典型传统表单代码
function submitUser() {
const name = $("#name").val();
const age = $("#age").val();
const email = $("#email").val();
if (!name) {
alert("姓名不能为空");
return;
}
$.ajax({
url: "/api/users",
method: "POST",
data: { name, age, email },
success: (res) => {
if (res.code === 0) {
alert("成功");
} else {
alert(res.message);
}
},
});
}
问题:
- 字段名是字符串(“name”、“age”)——拼写错误运行时才发现
- 字段类型不明确——
age是字符串还是数字? - 表单值 vs API 数据类型不一致
1.2 类型化后
interface UserForm {
name: string;
age: number;
email: string;
}
interface User {
id: string;
name: string;
age: number;
email: string;
}
type ApiResponse<T> =
| { code: 0; data: T }
| { code: 1; message: string; fields: string[] };
async function submitUser(form: UserForm): Promise<void> {
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const json: ApiResponse<User> = await res.json();
if (json.code === 0) {
console.log("成功:", json.data.id);
} else {
console.error("失败:", json.message);
// 高亮错误字段
}
}
类型让代码逻辑清晰、出错提前。
1.3 实战:react-hook-form + zod
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const UserFormSchema = z.object({
name: z.string().min(2, "姓名至少 2 个字符"),
age: z.number().int().min(0).max(150),
email: z.string().email("邮箱格式不正确"),
role: z.enum(["user", "admin"]),
});
type UserForm = z.infer<typeof UserFormSchema>;
function UserFormComponent() {
const { register, handleSubmit, formState: { errors } } = useForm<UserForm>({
resolver: zodResolver(UserFormSchema),
});
const onSubmit = (data: UserForm) => {
// data 是完全类型化的
console.log(data.name, data.age, data.email, data.role);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} />
{errors.name && <span>{errors.name.message}</span>}
{/* ... 其他字段 */}
</form>
);
}
zod schema 是单一来源——既做运行时验证,又推断出类型。
二、API 请求类型化
2.1 用 zod 推断 API 类型
// 定义 schema
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
age: z.number(),
createdAt: z.string().datetime(),
});
type User = z.infer<typeof UserSchema>;
// 用
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
const data = await res.json();
return UserSchema.parse(data); // 验证 + 推断
}
好处:
- 字段名拼错 → 运行时立刻报错
- 字段类型不对 → 运行时立刻报错
- IDE 自动补全
2.2 实战:zod schema 复用
// schemas/user.ts
export const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
age: z.number(),
});
export const CreateUserSchema = UserSchema.omit({ id: true });
export const UpdateUserSchema = CreateUserSchema.partial();
export type User = z.infer<typeof UserSchema>;
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
// api/users.ts
import { UserSchema, CreateUserSchema, UpdateUserSchema } from "@/schemas/user";
import type { User, CreateUserInput, UpdateUserInput } from "@/schemas/user";
export async function createUser(input: CreateUserInput): Promise<User> {
const validated = CreateUserSchema.parse(input); // 验证输入
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(validated),
});
const data = await res.json();
return UserSchema.parse(data); // 验证输出
}
输入输出都有类型 + 运行时验证——前后端契约自动校验。
2.3 实战:fetch 包装
async function typedFetch<T>(
url: string,
schema: z.ZodSchema<T>,
options?: RequestInit
): Promise<T> {
const res = await fetch(url, options);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
return schema.parse(data);
}
// 用法
const user = await typedFetch("/api/users/123", UserSchema);
const users = await typedFetch("/api/users", z.array(UserSchema));
所有 API 调用都有类型 + 验证——不再用裸 fetch。
三、状态机类型化
3.1 用 discriminated union 表达状态机
// 订单状态机
type OrderState =
| { status: "pending" }
| { status: "paid"; paidAt: Date; transactionId: string }
| { status: "shipped"; shippedAt: Date; trackingNumber: string }
| { status: "delivered"; deliveredAt: Date }
| { status: "cancelled"; cancelledAt: Date; reason: string };
// 转换函数
function transition(state: OrderState): OrderState {
switch (state.status) {
case "pending":
// 只允许从 pending 转 paid
return {
status: "paid",
paidAt: new Date(),
transactionId: generateId(),
};
// ... 其他转换
}
}
每个状态有不同字段,discriminated union 自动精确推断。
3.2 实战:XState + TypeScript
XState 是专业状态机库,跟 TS 配合很好:
import { createMachine } from "xstate";
const orderMachine = createMachine({
id: "order",
initial: "pending",
states: {
pending: {
on: { PAY: "paid" },
},
paid: {
on: { SHIP: "shipped", CANCEL: "cancelled" },
},
shipped: {
on: { DELIVER: "delivered" },
},
delivered: {
type: "final",
},
cancelled: {
type: "final",
},
},
});
// 自动类型化的事件
const state = orderMachine.transition("pending", { type: "PAY" });
// state.value 是 "paid"
// 错误的事件
orderMachine.transition("pending", { type: "SHIP" });
// 报错 - pending 状态没有 SHIP 事件
3.3 实战:表单提交流程
// 表单状态机
type FormState<T> =
| { status: "idle" }
| { status: "editing"; values: T; errors: Partial<Record<keyof T, string>> }
| { status: "submitting"; values: T }
| { status: "success"; data: User }
| { status: "error"; values: T; error: Error };
type FormEvent<T> =
| { type: "EDIT"; field: keyof T; value: T[keyof T] }
| { type: "SUBMIT" }
| { type: "RESET" }
| { type: "SUCCESS"; data: User }
| { type: "ERROR"; error: Error };
function reducer<T>(state: FormState<T>, event: FormEvent<T>): FormState<T> {
switch (state.status) {
case "idle":
if (event.type === "EDIT") {
return { status: "editing", values: { [event.field]: event.value } as T, errors: {} };
}
return state;
// ...
}
}
四、React 组件类型化
4.1 Props 类型化
interface ButtonProps {
children: React.ReactNode;
onClick: () => void;
variant?: "primary" | "secondary" | "danger";
disabled?: boolean;
}
function Button({ children, onClick, variant = "primary", disabled }: ButtonProps) {
return (
<button
className={`btn btn-${variant}`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
}
4.2 泛型组件
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map(item => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// 用法
<List
items={users}
renderItem={user => <span>{user.name}</span>}
keyExtractor={user => user.id}
/>
// renderItem 里的 user 是 User 类型
4.3 实战:类型安全的组件 prop forwarding
import { ComponentPropsWithoutRef, ElementType, ReactNode } from "react";
type PolymorphicProps<E extends ElementType> = {
as?: E;
children?: ReactNode;
} & ComponentPropsWithoutRef<E>;
function Box<E extends ElementType = "div">({
as,
children,
...rest
}: PolymorphicProps<E>) {
const Component = as || "div";
return <Component {...rest}>{children}</Component>;
}
// 用法
<Box>Default div</Box>
<Box as="button" onClick={() => {}}>Button</Box> // 有 button 的 props
<Box as="a" href="/foo">Link</Box> // 有 a 的 props
五、数据库类型化
5.1 用 Prisma 推断类型
// schema.prisma
model User {
id String @id @default(cuid())
name String
email String @unique
posts Post[]
}
model Post {
id String @id @default(cuid())
title String
author User @relation(fields: [authorId], references: [id])
authorId String
}
Prisma 自动生成类型:
import { User, Post } from "@prisma/client";
async function getUser(id: string): Promise<User | null> {
return prisma.user.findUnique({ where: { id } });
}
async function createPost(data: { title: string; authorId: string }): Promise<Post> {
return prisma.post.create({ data });
}
5.2 用 Drizzle ORM
import { pgTable, text, integer } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: text("id").primaryKey(),
name: text("name").notNull(),
age: integer("age").notNull(),
});
type User = typeof users.$inferSelect; // 自动推断
type NewUser = typeof users.$inferInsert;
await db.insert(users).values({ id: "1", name: "Alice", age: 30 });
schema 是单一来源——类型、运行时验证、SQL 迁移都用同一个定义。
六、类型化业务规则
6.1 用品牌类型防止"非法值"
// 原始类型太宽
type Email = string; // 任何字符串都算 Email
type UserId = string; // 任何字符串都算 UserId
// 用品牌类型
type Brand<K, T> = K & { readonly __brand: T };
type Email = Brand<string, "Email">;
type UserId = Brand<string, "UserId">;
// 创建函数做运行时验证
function createEmail(s: string): Email {
if (!/^.+@.+\..+$/.test(s)) throw new Error("Invalid email");
return s as Email;
}
function createUserId(s: string): UserId {
if (!s.startsWith("user_")) throw new Error("Invalid user id");
return s as UserId;
}
// 用法
const email: Email = createEmail("alice@example.com");
const userId: UserId = createUserId("user_123");
// 编译时检查 - Email 和 UserId 不能互换
function fetchUser(id: UserId) { ... }
fetchUser(email); // 报错!Email 不能赋值给 UserId
6.2 实战:API 参数类型化
// API 入参类型化
type ApiEndpoint = "users" | "orders" | "products";
type ApiMethod = "GET" | "POST" | "PUT" | "DELETE";
interface ApiRequest<E extends ApiEndpoint, M extends ApiMethod> {
endpoint: E;
method: M;
body?: E extends "users" ? CreateUserInput :
E extends "orders" ? CreateOrderInput :
never;
}
async function call<E extends ApiEndpoint, M extends ApiMethod>(
req: ApiRequest<E, M>
): Promise<unknown> {
// ...
}
call({
endpoint: "users",
method: "POST",
body: { name: "Alice", email: "alice@example.com", age: 30 },
}); // OK
call({
endpoint: "users",
method: "POST",
body: { productId: "1", quantity: 2 }, // 报错 - 不匹配 users 的 body
});
七、实战:200 个表单全面类型化
7.1 项目背景
公司有个老 SaaS 产品:
- 200+ 表单(用户管理、订单、报表设置、权限配置……)
- JS + jQuery,几千行手写验证
- 改一处 bug,引出新 bug
- 没人敢动
7.2 目标
- 表单字段类型化(每个字段有类型)
- 验证逻辑统一(zod schema)
- 提交逻辑类型化(API 调用 + 错误处理)
7.3 第一步:定义领域模型
// src/types/domain.ts
export interface User {
id: string;
name: string;
email: string;
age: number;
role: "user" | "admin";
createdAt: Date;
}
export interface Order {
id: string;
userId: string;
products: OrderItem[];
total: number;
status: "pending" | "paid" | "shipped" | "delivered" | "cancelled";
}
export interface Product {
id: string;
name: string;
price: number;
stock: number;
}
7.4 第二步:zod schemas
// src/schemas/user.ts
import { z } from "zod";
export const UserSchema = z.object({
id: z.string().cuid(),
name: z.string().min(2).max(50),
email: z.string().email(),
age: z.number().int().min(0).max(150),
role: z.enum(["user", "admin"]),
createdAt: z.date(),
});
export const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
export const UpdateUserSchema = CreateUserSchema.partial();
7.5 第三步:类型化表单组件
// src/components/Form.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import type { ZodSchema } from "zod";
interface FormProps<TSchema extends ZodSchema> {
schema: TSchema;
onSubmit: (data: z.infer<TSchema>) => Promise<void>;
children: (form: UseFormReturn<z.infer<TSchema>>) => React.ReactNode;
}
export function Form<TSchema extends ZodSchema>({
schema,
onSubmit,
children,
}: FormProps<TSchema>) {
const form = useForm<z.infer<TSchema>>({
resolver: zodResolver(schema),
});
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{children(form)}
</form>
);
}
7.6 第四步:使用
import { Form } from "@/components/Form";
import { CreateUserSchema, type CreateUserInput } from "@/schemas/user";
import { createUser } from "@/api/users";
function CreateUserPage() {
return (
<Form<typeof CreateUserSchema>
schema={CreateUserSchema}
onSubmit={async (data: CreateUserInput) => {
const user = await createUser(data);
toast.success("创建成功");
router.push(`/users/${user.id}`);
}}
>
{(form) => (
<>
<input {...form.register("name")} />
{form.formState.errors.name && <span>{form.formState.errors.name.message}</span>}
<input type="number" {...form.register("age", { valueAsNumber: true })} />
<input {...form.register("email")} />
<select {...form.register("role")}>
<option value="user">用户</option>
<option value="admin">管理员</option>
</select>
<button type="submit">提交</button>
</>
)}
</Form>
);
}
7.7 效果
| 指标 | 迁移前 | 迁移后 |
|---|---|---|
| 字段拼写错误 | 每月 5+ 次 | 0(编译报错) |
| 字段类型错误 | 每月 3+ 次 | 0(编译报错) |
| 必填验证 | 手写 if | 自动(zod) |
| API 调用错误 | 经常 | 0(类型保护) |
| 新人上手 | 2 周 | 2 天 |
| 表单代码行数 | 平均 100+ | 平均 30 |
200 个表单 6 个月迁移完成,Bug 数下降 60%。
八、类型化的常见反模式
8.1 反模式 1:过度类型化
// NG - 类型太复杂,看不懂
type OverEngineered<T> = T extends Array<infer U>
? U extends Record<string, any>
? { [K in keyof U]: U[K] extends (...args: any[]) => any ? ReturnType<U[K]> : U[K] }
: never
: never;
如果类型比业务代码还长,就是过度类型化。
8.2 反模式 2:any 蔓延
// NG - any 让所有类型失效
function process(data: any) {
return data.foo.bar.baz; // 编译过,运行时炸
}
用 unknown + 类型守卫替代。
8.3 反模式 3:类型不匹配实际
// NG - 类型说 A,实际是 B
interface User {
age: number;
}
const user: User = JSON.parse(fs.readFileSync("user.json"));
// user.age 可能是 string(因为 JSON),但类型说 number
对外部数据用 zod 验证,不要相信类型。
九、本篇小结
| 场景 | 方案 |
|---|---|
| 表单 | react-hook-form + zod |
| API | zod schema 复用 + 推断 |
| 状态机 | discriminated union / XState |
| 组件 | 泛型组件 + ComponentPropsWithoutRef |
| 数据库 | Prisma / Drizzle 自动推断 |
| 业务规则 | 品牌类型防止"非法值" |
| 实战 | 收益 |
|---|---|
| 200 表单类型化 | Bug 减少 60% |
| API 类型化 | 字段名错误为 0 |
| 状态机 | 状态转换无遗漏 |
下一篇讲 TS 5.x 新特性深度解读——const type parameter、satisfies、decorator 等。