Type-safe với thư viện thật

Bài 14: Type-safe với thư viện thật

Lý thuyết là một chuyện, áp dụng vào thư viện thật là chuyện khác. Bài này sẽ tập trung vào hai kịch bản thực tế: Zod (validation library inference type) và React (generic components, discriminated unions, type-safe events).


#Vấn đề: Duplicated types giữa Zod và TypeScript

// ❌ Anti-pattern: khai báo type ở hai nơi
interface User {
  name: string;
  email: string;
  age: number;
}
 
const userSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number().min(0),
});
 
// Giờ có 2 nguồn truth — phải sync thủ công
// Thay đổi interface mà quên schema (hoặc ngược lại) → bug
// ✅ Đúng: derive type từ schema
const userSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number().min(0),
});
 
type User = z.infer<typeof userSchema>;
// { name: string; email: string; age: number }
 
// Một nguồn truth duy nhất — schema là source of truth

#Zod Schema → Type Inference

Zod cho phép bạn định nghĩa schema validation và tự động suy ra TypeScript type.

#Cơ bản

import { z } from "zod";
 
const loginSchema = z.object({
  email: z.string().email("Email không hợp lệ"),
  password: z.string().min(8, "Mật khẩu tối thiểu 8 ký tự"),
  remember: z.boolean().optional(),
});
 
type LoginInput = z.infer<typeof loginSchema>;
// { email: string; password: string; remember?: boolean }
 
// ✅ Type-safe validation
function handleLogin(data: unknown) {
  const result = loginSchema.safeParse(data);
 
  if (!result.success) {
    console.error(result.error.flatten());
    return;
  }
 
  // result.data có type LoginInput — type-safe
  console.log(result.data.email); // ✅ string
}

#Enum và Union

const roleSchema = z.enum(["admin", "user", "guest"]);
type Role = z.infer<typeof roleSchema>; // "admin" | "user" | "guest"
 
const statusSchema = z.union([
  z.object({ status: z.literal("success"), data: z.string() }),
  z.object({ status: z.literal("error"), message: z.string() }),
]);
type ApiResponse = z.infer<typeof statusSchema>;
// Discriminated union: { status: "success"; data: string } | { status: "error"; message: string }

#Transform và Refine

const userSchema = z.object({
  name: z.string().transform((s) => s.trim()),
  email: z.string().email().transform((s) => s.toLowerCase()),
  age: z.number().int().positive(),
});
 
// Type sau transform khác với input
type UserInput = z.input<typeof userSchema>;  // raw input
type UserOutput = z.output<typeof userSchema>; // sau transform

#Nested schemas

const addressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zip: z.string().regex(/^\d{5,6}$/),
});
 
const userSchema = z.object({
  name: z.string(),
  address: addressSchema,
  tags: z.array(z.string()),
  metadata: z.record(z.string(), z.unknown()),
});
 
type User = z.infer<typeof userSchema>;
// {
//   name: string;
//   address: { street: string; city: string; zip: string };
//   tags: string[];
//   metadata: Record<string, unknown>;
// }

#React Generic Components

#Component nhận type parameter

// ✅ Generic List component
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string;
}
 
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>{renderItem(item, index)}</li>
      ))}
    </ul>
  );
}
 
// ✅ TypeScript infer T từ props
<List
  items={users} // T inferred as User
  renderItem={(user) => <span>{user.name}</span>} // user: User
  keyExtractor={(user) => user.id} // user: User
/>

#Generic với constraint

// ✅ Component chỉ nhận object có id
interface HasId {
  id: string | number;
}
 
interface DataTableProps<T extends HasId> {
  data: T[];
  columns: Array<{
    key: keyof T;
    header: string;
    render?: (value: T[keyof T], row: T) => React.ReactNode;
  }>;
}
 
function DataTable<T extends HasId>({ data, columns }: DataTableProps<T>) {
  return (
    <table>
      <thead>
        <tr>
          {columns.map((col) => (
            <th key={String(col.key)}>{col.header}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {data.map((row) => (
          <tr key={row.id}>
            {columns.map((col) => (
              <td key={String(col.key)}>
                {col.render
                  ? col.render(row[col.key], row)
                  : String(row[col.key])}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

#Discriminated Unions trong Props

// ✅ Props thay đổi tùy variant
type ButtonProps =
  | { variant: "primary"; onClick: () => void; children: React.ReactNode }
  | { variant: "link"; href: string; children: React.ReactNode }
  | { variant: "icon"; icon: React.ComponentType; "aria-label": string };
 
function Button(props: ButtonProps) {
  switch (props.variant) {
    case "primary":
      return <button onClick={props.onClick}>{props.children}</button>;
    case "link":
      return <a href={props.href}>{props.children}</a>;
    case "icon":
      return (
        <button aria-label={props["aria-label"]}>
          <props.icon />
        </button>
      );
  }
}
 
// ✅ TypeScript kiểm tra props đúng theo variant
<Button variant="primary" onClick={() => console.log("clicked")}>
  Click me
</Button>
 
<Button variant="link" href="/about">About</Button>
 
// ❌ Error: thiếu onClick khi variant="primary"
<Button variant="primary">Click me</Button>

#Type-safe Event Handlers

// ✅ Event handler type-safe
interface EventMap {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "error": { code: number; message: string };
}
 
function useEvent<K extends keyof EventMap>(
  eventName: K,
  handler: (data: EventMap[K]) => void
) {
  useEffect(() => {
    const listener = (event: CustomEvent) => handler(event.detail);
    window.addEventListener(eventName, listener as EventListener);
    return () => window.removeEventListener(eventName, listener as EventListener);
  }, [eventName, handler]);
}
 
// ✅ TypeScript biết data type chính xác
useEvent("user:login", (data) => {
  console.log(data.userId); // ✅ string
  console.log(data.timestamp); // ✅ number
});
 
useEvent("error", (data) => {
  console.log(data.code); // ✅ number
  console.log(data.message); // ✅ string
});

#Polymorphic Component

// ✅ Component render element khác nhau
type AsProp<C extends React.ElementType> = {
  as?: C;
};
 
type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);
 
type PolymorphicComponentProps<
  C extends React.ElementType,
  Props = {}
> = React.PropsWithChildren<Props & AsProp<C>> &
  Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
 
interface TextProps {
  color?: "primary" | "secondary";
  size?: "sm" | "md" | "lg";
}
 
type TextComponentProps<C extends React.ElementType> =
  PolymorphicComponentProps<C, TextProps>;
 
function Text<C extends React.ElementType = "span">({
  as,
  color,
  size,
  children,
  ...rest
}: TextComponentProps<C>) {
  const Component = as || "span";
  return (
    <Component className={`text-${color} text-${size}`} {...rest}>
      {children}
    </Component>
  );
}
 
// ✅ Render as span (default)
<Text color="primary" size="md">Hello</Text>
 
// ✅ Render as h1 — nhận thêm h1 props
<Text as="h1" color="primary" size="lg">Title</Text>
 
// ✅ Render as link — nhận thêm a props
<Text as="a" href="/about" color="secondary">Link</Text>

#Type-safe Form với Zod + React

import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
 
// ✅ Schema là source of truth
const registerSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Mật khẩu không khớp",
  path: ["confirmPassword"],
});
 
type RegisterForm = z.infer<typeof registerSchema>;
 
function RegisterPage() {
  const { register, handleSubmit, formState: { errors } } = useForm<RegisterForm>({
    resolver: zodResolver(registerSchema),
  });
 
  const onSubmit = (data: RegisterForm) => {
    // ✅ data đã validated, type-safe
    console.log(data.name, data.email);
  };
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name")} />
      {errors.name && <span>{errors.name.message}</span>}
 
      <input {...register("email")} />
      {errors.email && <span>{errors.email.message}</span>}
 
      <input type="password" {...register("password")} />
 
      <input type="password" {...register("confirmPassword")} />
      {errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
 
      <button type="submit">Register</button>
    </form>
  );
}

#Bài tập

#Dễ

Viết Zod schema cho Product với name, price (positive number), category (enum: "electronics" | "clothing" | "food"). Derive type bằng z.infer.

#Trung bình

Viết generic React component Select<T> nhận options: T[], getOptionLabel: (item: T) => string, value: T, onChange: (value: T) => void.

#Khó

Viết polymorphic component Box<C extends React.ElementType> hỗ trợ prop as và type-safe cho element-specific props (ví dụ: as="a" thì nhận href, as="button" thì nhận onClick).


#Tổng kết

  • Zod schema là source of truth — dùng z.infer để derive TypeScript type
  • Không duplicate type giữa Zod schema và TypeScript interface
  • React generic components infer type từ props
  • Discriminated unions tạo type-safe props theo variant
  • Polymorphic components dùng as prop để render element khác nhau
  • Zod + react-hook-form tạo form validation type-safe end-to-end

Bài tiếp theo: Thiết kế thư viện type-safe (phần 1) — xây dựng mini validation library từ đầu.