Thiết kế thư viện type-safe (phần 1)

Bài 15: Thiết kế thư viện type-safe (phần 1)

Bạn đã dùng Zod, đã hiểu generics, utility types, conditional types. Giờ là lúc tự tay xây dựng một validation library mini từ đầu. Đây là cách tốt nhất để hiểu sâu cách TypeScript inference hoạt động trong thư viện thật.

Mục tiêu: xây dựng MiniSchema — một mini Zod với:

  • Schema primitives: string(), number(), boolean()
  • Method .parse() validate và trả về typed result
  • Chaining: .optional(), .min(), .max()
  • Type inference từ schema

#Bước 1: Abstract Base Schema

Mọi schema đều có một điểm chung: nhận input, validate, trả về output. Dùng generic để mô tả input/output type.

// ✅ Base class cho mọi schema
abstract class Schema<T> {
  abstract parse(input: unknown): T;
 
  // Optional chaining — trả về type mới
  optional(): OptionalSchema<T> {
    return new OptionalSchema(this);
  }
}
 
class OptionalSchema<T> extends Schema<T | undefined> {
  constructor(private inner: Schema<T>) {
    super();
  }
 
  parse(input: unknown): T | undefined {
    if (input === undefined || input === null) {
      return undefined;
    }
    return this.inner.parse(input);
  }
}

Anti-pattern ngay từ đầu: Trả về any từ .parse().

// ❌ Anti-pattern
class Schema {
  parse(input: unknown): any {
    return input; // Mất type safety — vô nghĩa
  }
}

#Bước 2: Primitive Schemas

// ✅ String Schema
class StringSchema extends Schema<string> {
  private minLength?: number;
  private maxLength?: number;
 
  parse(input: unknown): string {
    if (typeof input !== "string") {
      throw new Error(`Expected string, got ${typeof input}`);
    }
    if (this.minLength !== undefined && input.length < this.minLength) {
      throw new Error(`String must have at least ${this.minLength} characters`);
    }
    if (this.maxLength !== undefined && input.length > this.maxLength) {
      throw new Error(`String must have at most ${this.maxLength} characters`);
    }
    return input;
  }
 
  min(n: number): this {
    this.minLength = n;
    return this;
  }
 
  max(n: number): this {
    this.maxLength = n;
    return this;
  }
}
 
// ✅ Number Schema
class NumberSchema extends Schema<number> {
  private minValue?: number;
  private maxValue?: number;
 
  parse(input: unknown): number {
    if (typeof input !== "number" || isNaN(input)) {
      throw new Error(`Expected number, got ${typeof input}`);
    }
    if (this.minValue !== undefined && input < this.minValue) {
      throw new Error(`Number must be at least ${this.minValue}`);
    }
    if (this.maxValue !== undefined && input > this.maxValue) {
      throw new Error(`Number must be at most ${this.maxValue}`);
    }
    return input;
  }
 
  min(n: number): this {
    this.minValue = n;
    return this;
  }
 
  max(n: number): this {
    this.maxValue = n;
    return this;
  }
}
 
// ✅ Boolean Schema
class BooleanSchema extends Schema<boolean> {
  parse(input: unknown): boolean {
    if (typeof input !== "boolean") {
      throw new Error(`Expected boolean, got ${typeof input}`);
    }
    return input;
  }
}

#Bước 3: Factory Functions

Tạo shortcut functions để sử dụng gọn hơn.

// ✅ Factory functions — API gọn gàng
function string(): StringSchema {
  return new StringSchema();
}
 
function number(): NumberSchema {
  return new NumberSchema();
}
 
function boolean(): BooleanSchema {
  return new BooleanSchema();
}
 
// ✅ Sử dụng
const nameSchema = string().min(2).max(50);
nameSchema.parse("An"); // ✅ "An"
nameSchema.parse("A"); // ❌ Error: String must have at least 2 characters
 
const ageSchema = number().min(0).max(150);
ageSchema.parse(25); // ✅ 25
ageSchema.parse(-1); // ❌ Error: Number must be at least 0
 
const activeSchema = boolean();
activeSchema.parse(true); // ✅ true
activeSchema.parse("yes"); // ❌ Error: Expected boolean, got string

#Bước 4: Type Inference — Phần quan trọng nhất

Đây là phần thú vị nhất: làm sao để typeof schema suy ra được output type?

// ✅ Type inference helper
type Infer<T extends Schema<any>> = T extends Schema<infer U> ? U : never;
 
// Test
const nameSchema = string(); // StringSchema extends Schema<string>
type NameType = Infer<typeof nameSchema>; // string ✅
 
const ageSchema = number(); // NumberSchema extends Schema<number>
type AgeType = Infer<typeof ageSchema>; // number ✅
 
const optName = string().optional(); // OptionalSchema<string>
type OptNameType = Infer<typeof optName>; // string | undefined ✅

Tại sao dùng infer?

// ❌ Không dùng infer — phải manually track type
class StringSchema extends Schema<string> {}
// Nếu đổi Schema<T> thành Schema<T, U>, mọi thứ phải sửa
 
// ✅ Dùng infer — tự động rút trích type
type Infer<T extends Schema<any>> = T extends Schema<infer U> ? U : never;
// Hoạt động với bất kỳ schema nào extends Schema<T>

#Bước 5: Array Schema

class ArraySchema<T> extends Schema<T[]> {
  constructor(private itemSchema: Schema<T>) {
    super();
  }
 
  parse(input: unknown): T[] {
    if (!Array.isArray(input)) {
      throw new Error(`Expected array, got ${typeof input}`);
    }
    return input.map((item, index) => {
      try {
        return this.itemSchema.parse(item);
      } catch (e) {
        throw new Error(`Item at index ${index}: ${(e as Error).message}`);
      }
    });
  }
 
  min(n: number): this {
    const originalParse = this.parse.bind(this);
    this.parse = (input: unknown): T[] => {
      const result = originalParse(input);
      if (result.length < n) {
        throw new Error(`Array must have at least ${n} items`);
      }
      return result;
    };
    return this;
  }
}
 
function array<T>(itemSchema: Schema<T>): ArraySchema<T> {
  return new ArraySchema(itemSchema);
}
 
// ✅ Type inference hoạt động
const tagsSchema = array(string());
type TagsType = Infer<typeof tagsSchema>; // string[] ✅

#Bước 6: Literal Schema

class LiteralSchema<T extends string | number | boolean> extends Schema<T> {
  constructor(private value: T) {
    super();
  }
 
  parse(input: unknown): T {
    if (input !== this.value) {
      throw new Error(`Expected ${JSON.stringify(this.value)}, got ${JSON.stringify(input)}`);
    }
    return this.value;
  }
}
 
function literal<T extends string | number | boolean>(value: T): LiteralSchema<T> {
  return new LiteralSchema(value);
}
 
// ✅ Literal type inference
const statusSchema = literal("active");
type StatusType = Infer<typeof statusSchema>; // "active" ✅

#Bước 7: Nullable và Nullish

class NullableSchema<T> extends Schema<T | null> {
  constructor(private inner: Schema<T>) {
    super();
  }
 
  parse(input: unknown): T | null {
    if (input === null) return null;
    return this.inner.parse(input);
  }
}
 
// Thêm method vào Schema base
// (Trong thực tế, thêm vào abstract class ở Bước 1)

#Kết quả: Sử dụng hoàn chỉnh

// ✅ API giống Zod
const userSchema = object({
  name: string().min(2),
  age: number().min(0),
  email: string(),
  isActive: boolean(),
  tags: array(string()),
});
 
type User = Infer<typeof userSchema>;
// {
//   name: string;
//   age: number;
//   email: string;
//   isActive: boolean;
//   tags: string[];
// }
 
const user = userSchema.parse({
  name: "An",
  age: 25,
  email: "an@example.com",
  isActive: true,
  tags: ["dev", "ts"],
});
// user có type User — type-safe ✅

#Bài tập

#Dễ

Thêm method .email() vào StringSchema kiểm tra email hợp lệ bằng regex.

#Trung bình

Viết UnionSchema nhận nhiều schemas, parse thành công nếu input match bất kỳ schema nào.

#Khó

Viết TupleSchema nhận mảng các schemas theo thứ tự, parse tuple type chính xác: TupleSchema<[StringSchema, NumberSchema]>[string, number].


#Tổng kết

  • Abstract base Schema<T> với generic T là output type
  • Mỗi schema primitive (string, number, boolean) extends Schema<T> với T cụ thể
  • Factory functions (string(), number()) tạo API gọn gàng
  • type Infer<T extends Schema<any>> = T extends Schema<infer U> ? U : never — type inference key
  • Chaining methods (min, max, optional) trả về this hoặc schema type mới
  • Không bao giờ dùng any trong schema output — generic T phải flow đúng

Bài tiếp theo: Thiết kế thư viện type-safe (phần 2) — object schemas, safeParse, nested inference, và đóng gói.