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
asprop để 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.