类型编程在业务中的实战

类型编程在业务中的实战

写在前面

前面六篇从类型基础讲到了 .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);
            }
        },
    });
}

问题:

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);  // 验证 + 推断
}

好处:

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 产品:

7.2 目标

  1. 表单字段类型化(每个字段有类型)
  2. 验证逻辑统一(zod schema)
  3. 提交逻辑类型化(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 等。

参考资料