0

Validating unknown in TypeScript Without a Schema Library: Type Guards, Assertion Functions and a 40-Line Mini Validator

Mình đã review khá nhiều codebase TypeScript, và lỗi hay gặp nhất không nằm ở thuật toán. Nó nằm ở một dòng rất vô hại: const user = await res.json() as User. Compiler không báo gì, IDE autocomplete đủ cả, test với mock data cũng pass. Rồi một hôm backend đổi id từ number sang string, hoặc trả về null thay vì object, và lỗi Cannot read properties of undefined xuất hiện ở một component cách chỗ fetch ba tầng. Bài này nói về cách validate dữ liệu unknown bằng TypeScript thuần, không cần Zod, Valibot hay thư viện nào: dùng type guard, assertion function, và một bộ validator nhỏ chừng 40 dòng mà bạn có thể copy vào project ngay hôm nay.

as là một lời hứa, không phải một phép kiểm tra

Viết as User tức là bạn bảo compiler: “tin tôi đi, cái này là User”. TypeScript bị xoá hết ở runtime, nên trong file JS sau khi build không còn gì kiểm tra lời hứa đó cả. Tệ hơn, res.json() và JSON.parse() trả về any, nên nhiều khi bạn còn chẳng cần as, any tự lan đi khắp code.

Nguyên tắc đầu tiên: mọi dữ liệu đi qua ranh giới hệ thống đều phải mang kiểu unknown. unknown bắt bạn chứng minh kiểu trước khi dùng, còn any thì cho bạn làm gì cũng được.

flowchart LR
    A[HTTP API] --> V{Validate layer}
        B[localStorage] --> V
            C[postMessage / WebSocket] --> V
                D[process.env] --> V
                    V -- hợp lệ --> E[Domain code: typed]
                        V -- không hợp lệ --> F[Error + log + fallback]
                        ```
                        
                        Sau lớp validate, code bên trong được phép tin vào type. Trước lớp đó thì không có gì đáng tin cả.
                        
                        ## Viên gạch cơ bản: type guard và narrowing
                        
                        Type guard là một function trả về `x is T`. Từ TypeScript 4.9, toán tử `in` đã narrow được object chưa biết, nên giờ viết guard bằng tay gọn hơn trước nhiều:
                        
                        ```ts
                        interface User {
                          id: number;
                            email: string;
                            }
                            
                            function isUser(x: unknown): x is User {
                              if (typeof x !== 'object' || x === null) return false;
                                if (!('id' in x) || !('email' in x)) return false;
                                  // TS 4.9+: x lúc này là object & Record<'id', unknown> & Record<'email', unknown>
                                    return typeof x.id === 'number' && typeof x.email === 'string';
                                    }
                                    
                                    const data: unknown = JSON.parse(raw);
                                    if (isUser(data)) {
                                      console.log(data.email.toLowerCase()); // OK, data: User
                                      }
                                      ```
                                      
                                      Có vài cái bẫy cần nhớ:
                                      
                                      - `typeof null === 'object'`, nên lúc nào cũng phải check `x !== null`.
                                      - `typeof NaN === 'number'`. Nếu field là số thật thì nên dùng `Number.isFinite(x)`.
                                      - Array cũng là object. Khi cần object thuần thì thêm `!Array.isArray(x)`.
                                      - Từ **TypeScript 5.5**, compiler tự suy ra type predicate cho những arrow function đơn giản, ví dụ `items.filter(x => x !== null)` giờ trả về `T[]` thay vì `(T | null)[]`. Tiện thật, nhưng chỉ áp dụng cho trường hợp đơn giản. Object lồng nhau thì vẫn phải tự viết.
                                      
                                      Viết tay kiểu này ổn với 2–3 type. Đến khi có 30 DTO thì bạn sẽ muốn có thứ gì đó composable hơn.
                                      
                                      ## Tự viết mini validator có suy luận kiểu
                                      
                                      Ý tưởng chính của Zod thực ra rất đơn giản: mỗi validator là một function `(x: unknown) => x is T`, rồi ghép chúng lại với nhau. Mấu chốt là **type được suy ra từ validator**, nên bạn không phải khai báo interface hai lần.
                                      
                                      ```ts
                                      export type Guard<T> = (x: unknown) => x is T;
                                      export type Infer<G> = G extends Guard<infer T> ? T : never;
                                      
                                      export const isRecord = (x: unknown): x is Record<string, unknown> =>
                                        typeof x === 'object' && x !== null && !Array.isArray(x);
                                        
                                        export const str: Guard<string> = (x): x is string => typeof x === 'string';
                                        export const num: Guard<number> = (x): x is number =>
                                          typeof x === 'number' && Number.isFinite(x);
                                          export const bool: Guard<boolean> = (x): x is boolean => typeof x === 'boolean';
                                          
                                          export function arrayOf<T>(item: Guard<T>): Guard<T[]> {
                                            return (x): x is T[] => Array.isArray(x) && x.every(item);
                                            }
                                            
                                            export function optional<T>(g: Guard<T>): Guard<T | undefined> {
                                              return (x): x is T | undefined => x === undefined || g(x);
                                              }
                                              
                                              // const type parameter: TypeScript 5.0+
                                              export function literal<const L extends readonly (string | number)[]>(
                                                ...values: L
                                                ): Guard<L[number]> {
                                                  return (x): x is L[number] => values.includes(x as L[number]);
                                                  }
                                                  
                                                  export function shape<S extends Record<string, Guard<unknown>>>(
                                                    spec: S
                                                    ): Guard<{ [K in keyof S]: Infer<S[K]> }> {
                                                      return (x): x is { [K in keyof S]: Infer<S[K]> } =>
                                                          isRecord(x) && Object.entries(spec).every(([k, g]) => g(x[k]));
                                                          }
                                                          
                                                          // Dùng thử
                                                          export const isUser = shape({
                                                            id: num,
                                                              email: str,
                                                                role: literal('admin', 'member'),
                                                                  tags: arrayOf(str),
                                                                    nickname: optional(str),
                                                                    });
                                                                    
                                                                    export type User = Infer<typeof isUser>;
                                                                    // { id: number; email: string; role: 'admin' | 'member'; tags: string[]; nickname: string | undefined }
                                                                    ```
                                                                    
                                                                    Với chưa đến 40 dòng, bạn đã có runtime check và static type đi từ cùng một nguồn. Thêm field vào `shape` thì type `User` cũng tự cập nhật theo.
                                                                    
                                                                    Nói thật về giới hạn của bản tự viết: `optional` ở đây cho ra `nickname: string | undefined` chứ không phải `nickname?: string`. Hai cái này khác nhau khi bật `exactOptionalPropertyTypes`. Muốn ra đúng `?:` thì phải dùng thêm mapped type có key remapping, và đó là lúc code bắt đầu phức tạp lên.
                                                                    
                                                                    ## Đặt validation đúng chỗ: assertion function ở boundary
                                                                    
                                                                    Type guard trả về boolean nên bạn phải tự viết `if`. Ở tầng fetch, mình thích dùng **assertion function** (có từ TS 3.7) hơn: dữ liệu sai thì throw, dữ liệu đúng thì phần code phía sau được narrow luôn.
                                                                    
                                                                    ```ts
                                                                    import { type Guard, isUser, type User } from './guards';
                                                                    
                                                                    export class ValidationError extends Error {
                                                                      constructor(public readonly source: string, public readonly payload: unknown) {
                                                                          super(`Payload từ ${source} không đúng shape`);
                                                                              this.name = 'ValidationError';
                                                                                }
                                                                                }
                                                                                
                                                                                // Phải là function declaration: arrow function làm assertion sẽ cần annotate type tường minh
                                                                                export function assertValid<T>(
                                                                                  guard: Guard<T>,
                                                                                    value: unknown,
                                                                                      source: string
                                                                                      ): asserts value is T {
                                                                                        if (!guard(value)) throw new ValidationError(source, value);
                                                                                        }
                                                                                        
                                                                                        export async function getUser(id: number): Promise<User> {
                                                                                          const res = await fetch(`/api/users/${id}`);
                                                                                            if (!res.ok) throw new Error(`HTTP ${res.status}`);
                                                                                            
                                                                                              const data: unknown = await res.json(); // chặn any ngay tại đây
                                                                                                assertValid(isUser, data, `GET /api/users/${id}`);
                                                                                                  return data; // data: User
                                                                                                  }
                                                                                                  ```
                                                                                                  
                                                                                                  ```mermaid
                                                                                                  sequenceDiagram
                                                                                                      participant UI as Component
                                                                                                          participant API as getUser()
                                                                                                              participant S as Server
                                                                                                                  UI->>API: getUser(42)
                                                                                                                      API->>S: GET /api/users/42
                                                                                                                          S-->>API: JSON (unknown)
                                                                                                                              API->>API: assertValid(isUser, data)
                                                                                                                                  alt hợp lệ
                                                                                                                                          API-->>UI: User
                                                                                                                                              else sai shape
                                                                                                                                                      API-->>UI: throw ValidationError
                                                                                                                                                          end
                                                                                                                                                          ```
                                                                                                                                                          
                                                                                                                                                          Điểm mấu chốt: lỗi xảy ra **ngay tại boundary**, có kèm tên endpoint và payload gốc. Bạn không phải lần ngược từ một `undefined` ở tận đâu đó trong render tree. Khi đẩy log lên hệ thống monitoring, hãy gửi luôn `source` và một phần `payload`. Lúc debug sự cố production, bạn sẽ thấy nó đáng giá.
                                                                                                                                                          
                                                                                                                                                          Một vài boundary hay bị quên: `localStorage.getItem` (user có thể còn data từ version cũ), `event.data` của `postMessage`, message từ WebSocket, `process.env`, và cả output của LLM khi bạn yêu cầu nó trả JSON.
                                                                                                                                                          
                                                                                                                                                          ## Khi nào nên chuyển sang thư viện?
                                                                                                                                                          
                                                                                                                                                          Bản tự viết đủ dùng khi:
                                                                                                                                                          
                                                                                                                                                          - Project nhỏ, hoặc là library mà bạn không muốn kéo thêm dependency.
                                                                                                                                                          - Bundle size quan trọng (widget nhúng, edge function).
                                                                                                                                                          - Chỉ cần biết đúng hay sai, không cần error message chi tiết.
                                                                                                                                                          
                                                                                                                                                          Nên dùng thư viện khi:
                                                                                                                                                          
                                                                                                                                                          - Cần báo lỗi theo path (`users[3].address.zip: expected string`) cho form validation.
                                                                                                                                                          - Cần transform/coerce (string sang Date, trim, default value).
                                                                                                                                                          - Có union phức tạp, recursive type, hoặc cần sinh JSON Schema/OpenAPI từ schema.
                                                                                                                                                          
                                                                                                                                                          Tự viết thêm phần error path thì cũng làm được, nhưng đến lúc đó bạn đang tự viết lại một thư viện. Quan trọng là hiểu cơ chế bên dưới, để khi dùng thư viện bạn biết nó đang làm gì và debug được khi type inference bị sai.
                                                                                                                                                          
                                                                                                                                                          ## Kết luận
                                                                                                                                                          
                                                                                                                                                          Mấy việc bạn có thể làm ngay trong sprint này:
                                                                                                                                                          
                                                                                                                                                          1. **Grep `as ` và `: any` ở các file fetch/API client.** Đổi kiểu trả về của `res.json()` và `JSON.parse()` thành `unknown`. Bật rule `@typescript-eslint/no-unsafe-assignment` và `no-unsafe-member-access` để chặn `any` lan ra.
                                                                                                                                                          2. **Đặt validation ở boundary, chỉ một lần.** Sau khi qua lớp validate, code domain tin vào type, không check lặp lại ở từng component.
                                                                                                                                                          3. **Dùng assertion function cho tầng I/O** để lỗi bị throw sớm, có kèm context, thay vì nổ muộn ở UI.
                                                                                                                                                          4. **Suy ra type từ validator** (`Infer<typeof isUser>`) thay vì khai báo interface riêng. Có một nguồn duy nhất thì type và runtime check không bị lệch nhau.
                                                                                                                                                          5. **Nâng TypeScript lên ít nhất 5.5** để có narrowing bằng `in` và inferred type predicates. Guard viết tay sẽ ngắn đi đáng kể.
                                                                                                                                                          
                                                                                                                                                          TypeScript chỉ bảo vệ được bạn trong phạm vi code mà compiler nhìn thấy. Mọi thứ từ bên ngoài vào đều phải kiểm tra lúc runtime, dù bằng 40 dòng tự viết hay bằng một thư viện.

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí