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

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

Ở bài trước, bạn đã xây dựng primitive schemas (string, number, boolean) với type inference. Bài này sẽ hoàn thiện library với object schema, nested inference, safeParse, viết test, và chuẩn bị publish lên npm.


#Object Schema — Phần phức tạp nhất

Object schema phải nhận một "shape" (object chứa các schema) và suy ra output type từ shape đó.

// ✅ Infer type từ shape object
type InferShape<T extends Record<string, Schema<any>>> = {
  [K in keyof T]: T[K] extends Schema<infer U> ? U : never;
};
 
// Test
const shape = {
  name: new StringSchema(),
  age: new NumberSchema(),
  active: new BooleanSchema(),
};
type Test = InferShape<typeof shape>;
// { name: string; age: number; active: boolean } ✅
// ✅ ObjectSchema implementation
class ObjectSchema<T extends Record<string, Schema<any>>> extends Schema<InferShape<T>> {
  private optionalFields = new Set<string>();
 
  constructor(private shape: T) {
    super();
  }
 
  parse(input: unknown): InferShape<T> {
    if (typeof input !== "object" || input === null || Array.isArray(input)) {
      throw new Error(`Expected object, got ${Array.isArray(input) ? "array" : typeof input}`);
    }
 
    const result = {} as Record<string, unknown>;
    const obj = input as Record<string, unknown>;
 
    for (const [key, schema] of Object.entries(this.shape)) {
      if (!(key in obj)) {
        if (this.optionalFields.has(key)) continue;
        throw new Error(`Missing required field: "${key}"`);
      }
 
      try {
        result[key] = schema.parse(obj[key]);
      } catch (e) {
        throw new Error(`Field "${key}": ${(e as Error).message}`);
      }
    }
 
    return result as InferShape<T>;
  }
 
  // Đánh dấu field optional
  partial(): ObjectSchema<T> {
    // Tạo bản copy với tất cả fields optional
    return new PartialObjectSchema(this.shape, new Set(Object.keys(this.shape)));
  }
}

#Nested Object Inference

TypeScript suy ra type đệ qua qua nested objects tự động.

// ✅ Nested schemas — inference xuyên suốt
const addressSchema = object({
  street: string(),
  city: string(),
  zip: string().min(5).max(6),
});
 
const userSchema = object({
  name: string().min(2),
  age: number().min(0),
  address: addressSchema,
  tags: array(string()),
});
 
type User = Infer<typeof userSchema>;
// {
//   name: string;
//   age: number;
//   address: { street: string; city: string; zip: string };
//   tags: string[];
// }
 
// ✅ Parse nested object — validate mọi level
const user = userSchema.parse({
  name: "An",
  age: 25,
  address: {
    street: "123 Main St",
    city: "Hanoi",
    zip: "100000",
  },
  tags: ["dev"],
});
 
console.log(user.address.city); // ✅ "Hanoi" — type-safe ở mọi level

#safeParse — Không throw error

Zod có .safeParse() trả về { success, data } | { success, error }. Đây là pattern tốt hơn throw.

// ✅ SafeParseResult type
type SafeParseResult<T> =
  | { success: true; data: T }
  | { success: false; error: string };
 
// Thêm method vào base Schema
abstract class Schema<T> {
  abstract parse(input: unknown): T;
 
  safeParse(input: unknown): SafeParseResult<T> {
    try {
      const data = this.parse(input);
      return { success: true, data };
    } catch (e) {
      return { success: false, error: (e as Error).message };
    }
  }
}
// ✅ Sử dụng safeParse
const nameSchema = string().min(2);
 
const result = nameSchema.safeParse("An");
if (result.success) {
  console.log(result.data); // ✅ "An" — type-safe
} else {
  console.log(result.error); // Error message
}
 
// Không throw — an toàn hơn trong production
const bad = nameSchema.safeParse("A");
if (!bad.success) {
  console.log(bad.error); // "String must have at least 2 characters"
}

#Refine — Custom Validation

Thêm validation logic tùy chỉnh sau khi parse cơ bản.

// ✅ Refine method
class Schema<T> {
  // ... existing code
 
  refine(predicate: (data: T) => boolean, message: string): RefineSchema<T> {
    return new RefineSchema(this, predicate, message);
  }
}
 
class RefineSchema<T> extends Schema<T> {
  constructor(
    private inner: Schema<T>,
    private predicate: (data: T) => boolean,
    private message: string
  ) {
    super();
  }
 
  parse(input: unknown): T {
    const result = this.inner.parse(input);
    if (!this.predicate(result)) {
      throw new Error(this.message);
    }
    return result;
  }
}
 
// ✅ Sử dụng
const passwordSchema = string()
  .min(8)
  .refine(
    (s) => /[A-Z]/.test(s),
    "Password must contain at least one uppercase letter"
  )
  .refine(
    (s) => /[0-9]/.test(s),
    "Password must contain at least one number"
  );
 
passwordSchema.parse("StrongPass1"); // ✅
passwordSchema.parse("weakpass"); // ❌ Error: must contain uppercase

#Transform — Chuyển đổi output

// ✅ Transform method
class Schema<T> {
  // ... existing code
 
  transform<U>(fn: (data: T) => U): TransformSchema<T, U> {
    return new TransformSchema(this, fn);
  }
}
 
class TransformSchema<TInput, TOutput> extends Schema<TOutput> {
  constructor(
    private inner: Schema<TInput>,
    private fn: (data: TInput) => TOutput
  ) {
    super();
  }
 
  parse(input: unknown): TOutput {
    const result = this.inner.parse(input);
    return this.fn(result);
  }
}
 
// ✅ Sử dụng
const trimmedString = string().transform((s) => s.trim());
const loweredEmail = string()
  .email()
  .transform((s) => s.toLowerCase());
 
trimmedString.parse("  hello  "); // ✅ "hello"
loweredEmail.parse("User@Example.COM"); // ✅ "user@example.com"

#Viết Tests

import { describe, it, expect } from "vitest";
import { string, number, boolean, object, array, literal } from "./mini-schema";
 
describe("StringSchema", () => {
  it("parse valid strings", () => {
    expect(string().parse("hello")).toBe("hello");
  });
 
  it("reject non-strings", () => {
    expect(() => string().parse(123)).toThrow("Expected string");
    expect(() => string().parse(null)).toThrow("Expected string");
  });
 
  it("validate min length", () => {
    const schema = string().min(3);
    expect(schema.parse("abc")).toBe("abc");
    expect(() => schema.parse("ab")).toThrow("at least 3");
  });
 
  it("validate max length", () => {
    const schema = string().max(5);
    expect(schema.parse("hello")).toBe("hello");
    expect(() => schema.parse("toolong")).toThrow("at most 5");
  });
 
  it("support chaining min and max", () => {
    const schema = string().min(2).max(10);
    expect(schema.parse("hello")).toBe("hello");
    expect(() => schema.parse("a")).toThrow();
    expect(() => schema.parse("a".repeat(11))).toThrow();
  });
});
 
describe("NumberSchema", () => {
  it("parse valid numbers", () => {
    expect(number().parse(42)).toBe(42);
    expect(number().parse(3.14)).toBe(3.14);
  });
 
  it("reject NaN", () => {
    expect(() => number().parse(NaN)).toThrow("Expected number");
  });
 
  it("reject non-numbers", () => {
    expect(() => number().parse("42")).toThrow("Expected number");
  });
});
 
describe("ObjectSchema", () => {
  it("parse valid objects", () => {
    const schema = object({ name: string(), age: number() });
    const result = schema.parse({ name: "An", age: 25 });
    expect(result).toEqual({ name: "An", age: 25 });
  });
 
  it("reject missing fields", () => {
    const schema = object({ name: string(), age: number() });
    expect(() => schema.parse({ name: "An" })).toThrow("Missing required field: \"age\"");
  });
 
  it("validate nested fields", () => {
    const schema = object({
      user: object({ name: string() }),
    });
    expect(() => schema.parse({ user: { name: 123 } })).toThrow();
  });
});
 
describe("safeParse", () => {
  it("return success result", () => {
    const result = string().safeParse("hello");
    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data).toBe("hello");
    }
  });
 
  it("return error result", () => {
    const result = string().safeParse(123);
    expect(result.success).toBe(false);
    if (!result.success) {
      expect(result.error).toContain("Expected string");
    }
  });
});

#Chuẩn bị Publish lên npm

#package.json

{
  "name": "mini-schema",
  "version": "1.0.0",
  "description": "Mini type-safe validation library",
  "main": "./dist/index.js",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "test": "vitest run",
    "prepublishOnly": "npm run build"
  }
}

#Cấu trúc thư mục

mini-schema/
├── src/
│   ├── index.ts          ← Re-export tất cả
│   ├── schema.ts         ← Base Schema class
│   ├── string.ts         ← StringSchema
│   ├── number.ts         ← NumberSchema
│   ├── boolean.ts        ← BooleanSchema
│   ├── object.ts         ← ObjectSchema
│   ├── array.ts          ← ArraySchema
│   └── utils.ts          ← Helper types
├── tests/
│   ├── string.test.ts
│   ├── number.test.ts
│   └── object.test.ts
├── package.json
├── tsconfig.json
└── README.md

#Khóa học Tổng kết

Qua 16 bài học, bạn đã đi từ TypeScript cơ bản đến thiết kế thư viện type-safe:

Module 1-2: Foundation

  • Generic constraints, mapped types, conditional types
  • Template literal types, recursive types

Module 3-4: Patterns

  • Discriminated unions, type narrowing
  • Declaration merging, module augmentation

Module 5: Production

  • tsconfig đúng cách, .d.ts files
  • Function overloads, builder pattern

Module 6-7: Advanced

  • Utility types reimplemented
  • Type-safe với Zod, React

Module 8: Capstone

  • Tự xây validation library từ đầu
  • Type inference, safeParse, tests, publish

#Bài tập

#Dễ

Thêm method .trim() vào StringSchema tự động loại bỏ khoảng trắng đầu cuối (dùng transform).

#Trung bình

Viết PartialObjectSchema cho phép một số field optional nhưng vẫn giữ type inference đúng.

#Khó

Hoàn thiện library: thêm .nullable(), .default(value), và .brand<T>() (branded types). Viết test đầy đủ cho tất cả features mới.


#Tổng kết

  • Object schema dùng mapped type InferShape<T> để suy ra output type từ shape
  • Nested schemas inference xuyên qua mọi level tự động
  • safeParse() pattern an toàn hơn parse() (không throw)
  • .refine() thêm custom validation, .transform() chuyển đổi output
  • Viết test cho mọi schema type — đây là production code
  • Dùng tsup để build dual CJS/ESM với .d.ts generation
  • Cấu trúc module tách biệt: mỗi schema type một file

Bạn đã có đủ kiến thức để xây dựng TypeScript library production-grade. Tiếp tục luyện tập và áp dụng vào dự án thật.