TypeScript Production · Phần 13 — Runtime Boundaries & Domain Types
Biến unknown từ API, storage và env thành domain value đáng tin: parse at the edge, schema evolution, branded types, DTO mapping và contract versioning.
Type annotation không validate JSON. Senior TypeScript đặt một quy tắc cứng:
dữ liệu ngoài process là unknown cho tới khi được parse.
Type erasure và reification
TypeScript xóa phần lớn type khi emit JavaScript:
async function readUser(): Promise<User> {
return fetch('/api/me').then((response) => response.json());
}
Promise<User> không tạo runtime check. Nếu server trả { id: 7 }, JavaScript
vẫn nhận object đó và lời hứa của annotation đã bị phá.
Reification ở boundary nghĩa là mang contract trở lại runtime bằng code có thể quan sát: schema/parser/codec, discriminator, constructor hoặc generated validator. Đích không phải “type tồn tại ở runtime”; đích là tạo bằng chứng trước khi cấp domain type.
raw bytes → authenticity/size → syntax parse → structural parse
→ normalize/transform → domain constructor → trusted core
Mỗi mũi tên có failure mode khác nhau. Gộp tất cả thành Invalid input làm mất
khả năng retry, alert và phân biệt tấn công với producer regression.
Boundary inventory
- HTTP request/response, webhook, message queue;
localStorage, IndexedDB, cookie;- environment variable và config file;
- form, URL/search params, DOM dataset;
- dependency không typed hoặc typed quá lạc quan.
Inventory theo direction + trust + owner, không chỉ protocol:
| Boundary | Input thật | Ai đổi contract | Failure policy |
|---|---|---|---|
| Public HTTP request | bytes/JSON | external caller | 4xx + field errors |
| Vendor webhook | signed raw bytes | vendor | quarantine/retry policy |
| Database row | driver values | migration/legacy data | fail closed + alert |
| Cache/local storage | stale serialized | app version cũ | migrate/evict |
| Environment/config | strings/files | deploy system | fail startup |
| Outbound API response | domain/write model | chính team mình | contract test |
Outbound cũng là boundary: serialize domain object trực tiếp có thể leak field,
Date, secret hoặc future enum member. Gán owner và parser/encoder cho từng edge.
async function getJson(url: string): Promise<unknown> {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
Đừng viết response.json() as User. Assertion đó biến network thành nguồn “đáng tin” mà không tạo bằng chứng.
response.json() nên được wrap về Promise<unknown> như trên; type declaration
quá lạc quan của platform/dependency không thay trust model của hệ thống.
Parse, rồi map sang domain
Ví dụ dùng một interface parser tối giản để không khóa kiến trúc vào thư viện cụ thể:
type Parser<T> = { parse(input: unknown): T };
type UserDto = {
id: string;
display_name: string;
created_at: string;
};
type User = {
id: UserId;
displayName: string;
createdAt: Date;
};
async function loadUser(id: UserId, parser: Parser<UserDto>): Promise<User> {
const dto = parser.parse(await getJson(`/api/users/${id}`));
return {
id: parseUserId(dto.id),
displayName: dto.display_name,
createdAt: parseIsoDate(dto.created_at),
};
}
DTO phản ánh wire contract. Domain type phản ánh điều application cần. Mapping này hấp thụ snake_case, date string, nullable legacy field và version drift ở một chỗ.
Schema/codec có Input khác Output
Parser không bắt buộc trả cùng representation nó nhận:
interface Schema<Input, Output> {
parse(input: Input): Output;
}
interface Codec<Wire, Value> extends Schema<Wire, Value> {
encode(value: Value): Wire;
}
type InputOf<S> = S extends Schema<infer Input, infer _Output> ? Input : never;
type OutputOf<S> =
S extends Schema<infer _Input, infer Output> ? Output : never;
Một schema HTTP thường là Schema<unknown, UserDto>; codec persistence có thể là
Codec<UserWire, User>. Đừng gọi input/output đều là User: bạn sẽ che mất nơi
representation đổi.
type UserWire = {
id: string;
created_at: string;
};
declare const userCodec: Codec<UserWire, User>;
type EncodedUser = InputOf<typeof userCodec>; // UserWire
type DecodedUser = OutputOf<typeof userCodec>; // User
Thứ tự preprocess/coerce/default/transform/refine
- preprocess: đọc raw shape trước schema chính, ví dụ trim query string;
- coerce: chấp nhận representation khác có chủ đích (
"42" → 42); - default: chỉ thay absence/
undefinedtheo contract, không nuốt invalid value; - transform: đổi value đã hợp lệ (
ISO string → Date, DTO → domain); - refine: kiểm predicate không biểu diễn được bằng primitive schema.
Thứ tự là semantics. Coerce trước khi giới hạn input có thể biến true, [] hay
"" thành số/string “hợp lệ” ngoài ý muốn. Default trước refine có thể che
producer quên field bắt buộc.
function parseLimit(input: unknown): number {
if (typeof input !== 'string' || !/^\d{1,3}$/.test(input)) {
throw new Error('limit must be 1-3 decimal digits');
}
const value = Number(input); // coerce sau khi raw form đã được giới hạn
if (value < 1 || value > 100) throw new Error('limit out of range');
return value;
}
Transform/refine nên pure và deterministic. Database lookup, authorization hay uniqueness check thuộc application layer; nhét I/O vào schema tạo N+1, retry mơ hồ và test chậm.
Parse, don’t validate-and-forget
function isPositive(value: number): boolean {
return Number.isFinite(value) && value > 0;
}
Boolean validator bắt caller nhớ correlation. Parser trả về type đã tinh chỉnh:
declare const positiveBrand: unique symbol;
type Positive = number & { readonly [positiveBrand]: true };
function parsePositive(value: unknown): Positive {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
throw new Error('Expected a positive finite number');
}
return value as Positive;
}
Brand/opaque type không tự validate; constructor là nơi tạo proof:
declare const userIdBrand: unique symbol;
export type UserId = string & { readonly [userIdBrand]: 'UserId' };
export function parseUserId(input: unknown): UserId {
if (typeof input !== 'string' || !/^usr_[a-z0-9]{8,32}$/.test(input)) {
throw new Error('invalid_user_id');
}
return input as UserId;
}
Assertion được phép ở một constructor nhỏ sau runtime proof. Không export
brand symbol, không export asUserId(raw: string) và không để test fixture cast
rải rác; nếu cần fixture, gọi constructor hoặc một test-only factory có invariant
rõ.
Opaque proof cũng có scope. EmailVerified có thể hết giá trị khi email đổi;
AuthorizedUserId phụ thuộc actor/request nên không nên lưu như primitive brand
vĩnh viễn.
JSON không round-trip domain object
const value = {
createdAt: new Date('2026-07-12T00:00:00Z'),
balance: 10n,
labels: new Map([['tier', 'pro']]),
nickname: undefined,
};
JSON.stringify(value) không phải encoder hợp lệ:
DategọitoJSON()và thành ISO string, parse lại không tự thànhDate;BigIntmặc định throwTypeError; phải có representation explicit;Map/Setkhông tự thành entries mong muốn, thường ra object rỗng;undefined/function/symbol bị bỏ trong object, thànhnulltrong array;NaN/Infinitythànhnull; integer quá safe range có thể mất precision.
Định nghĩa wire model và encode/decode đối xứng:
type AccountWire = {
createdAt: string;
balanceMinor: string; // decimal BigInt representation
labels: ReadonlyArray<readonly [string, string]>;
};
type Account = {
createdAt: Date;
balanceMinor: bigint;
labels: ReadonlyMap<string, string>;
};
Round-trip test phải kiểm decode(encode(domain)), không snapshot JSON rồi giả
định TypeScript annotation sẽ phục hồi representation.
Tách DTO, domain và write model
type UserDto = {
id: string;
display_name: string;
avatar_url: string | null;
created_at: string;
};
type User = {
id: UserId;
displayName: string;
avatarUrl: URL | null;
createdAt: Date;
};
type CreateUser = {
displayName: string;
avatarUrl?: URL | null;
};
DTO mirror producer/wire. Domain giữ invariant và rich value. Write model chỉ
expose field actor được phép gửi; đừng dùng Partial<User> làm PATCH vì nó kéo
id, createdAt và representation domain ra transport.
exactOptionalPropertyTypes và PATCH semantics
type UserPatch = {
displayName?: string; // absent = giữ nguyên
avatarUrl?: string | null; // null = xóa, absent = giữ nguyên
};
const rename: UserPatch = { displayName: 'Ada' };
const clearAvatar: UserPatch = { avatarUrl: null };
// @ts-expect-error với exactOptionalPropertyTypes
const ambiguous: UserPatch = { displayName: undefined };
Absence khác property hiện diện với value undefined; toán tử in và
Object.hasOwn quan sát được khác biệt. JSON lại không biểu diễn undefined ổn
định, nên PATCH nên dùng omission/no-op và null/operation explicit cho clear.
Parser phải reject key present-but-undefined nếu contract không cho phép, và chỉ đọc own property để tránh prototype pollution:
function isRecord(input: unknown): input is Record<string, unknown> {
return typeof input === 'object' && input !== null && !Array.isArray(input);
}
function parsePatch(input: unknown): UserPatch {
if (!isRecord(input)) throw new Error('patch must be an object');
const patch: UserPatch = {};
if (Object.hasOwn(input, 'displayName')) {
patch.displayName = parseDisplayName(input.displayName);
}
return patch;
}
Error của boundary là dữ liệu vận hành
Taxonomy tối thiểu:
type BoundaryError =
| { kind: 'payload_too_large'; limitBytes: number }
| { kind: 'malformed_json'; offset?: number }
| { kind: 'schema'; issues: readonly ParseIssue[] }
| { kind: 'unsupported_version'; version: string }
| { kind: 'normalization'; code: string }
| { kind: 'unauthenticated' };
type ParseIssue = {
path: readonly (string | number)[];
code: string;
expected?: string;
};
Error nên cho biết boundary (GET /users/:id, APP_CONFIG), safe path/code,
schema version và correlation ID. Không log raw received value: password/token/PII
có thể nằm đúng field fail validation.
Observability nên emit metric theo boundary + error.kind + issue.code, sample
trace có redaction và giữ payload hash/size thay raw body. Cardinality không lấy
user ID, URL đầy đủ hoặc error message tự do làm label.
Đừng catch validation error rồi trả null; bạn sẽ biến producer regression
thành bug xa nguồn. Nhưng cũng đừng alert mọi bad public request: taxonomy quyết
định 4xx, retry, dead-letter hay page on-call.
Evolution: tolerant reader có giới hạn
Tolerant reader chấp nhận unknown extra fields khi contract cho phép; nó không biến required field thành optional hay coerce sai type. Meaning đổi phải có version.
type UserCreatedWebhook =
| { version: 1; event: 'user.created'; name: string }
| { version: 2; event: 'user.created'; firstName: string; lastName: string };
type CurrentUserCreated = {
event: 'user.created';
displayName: string;
};
function assertNever(_value: never): never {
throw new Error('unreachable_variant');
}
function normalize(event: UserCreatedWebhook): CurrentUserCreated {
switch (event.version) {
case 1:
return { event: event.event, displayName: event.name };
case 2:
return {
event: event.event,
displayName: `${event.firstName} ${event.lastName}`,
};
default:
return assertNever(event);
}
}
Webhook pipeline có thứ tự bảo mật riêng:
- giới hạn bytes và giữ raw body;
- verify timestamp/signature trên raw bytes, chống replay;
- parse JSON thành
unknown; - parse discriminator/version và payload;
- normalize về current internal event;
- idempotency/dedup;
- authorization/business transition.
Unknown version không được cast về latest. Quarantine/dead-letter cùng metadata an toàn; HTTP response/retry phải theo provider contract để tránh retry storm. Giữ fixture cho từng version còn support và deadline xóa migration cũ.
Validation không phải authorization hay business invariant
Schema chứng minh shape/local predicate: UUID format, string length, enum, ISO date. Nó không chứng minh:
- signature/token thuộc principal hợp lệ;
- actor được sửa user này;
- email chưa tồn tại ở database;
- order đang ở state cho phép cancel;
- balance đủ tại thời điểm transaction commit.
Authorization cần request context; invariant cần aggregate/current state và có thể race. Đặt chúng sau parse/normalize nhưng trong transaction/application policy phù hợp. Trả error taxonomy khác để không leak “resource có tồn tại”.
Schema-first, type-first hay codegen?
| Cách | Điểm mạnh | Rủi ro chính |
|---|---|---|
| Schema-first | runtime proof là source of truth | vendor DSL/type inference lan rộng |
| Type-first + parser tay | domain type rõ, ít dependency | type/parser drift, boilerplate |
| Codegen từ OpenAPI/Proto | cross-service contract, nhiều model | spec fidelity, generator/version diff |
Schema-first hợp boundary app nhỏ/vừa; giữ inferred schema types ở adapter rồi map sang domain. Type-first hợp invariant/domain constructor nhưng cần contract tests. Codegen hợp protocol lớn/đa ngôn ngữ; generated type vẫn không thay runtime limits, auth hay mapping domain.
Decision rule: chọn một source of truth cho wire contract, không ép nó làm source of truth cho domain/write model. CI phải phát hiện drift giữa schema, generated artifact và producer fixture.
Performance và security limits
Validation trên input không tin cậy phải có budget trước khi schema sâu chạy:
- compressed/decompressed byte limit và content type allowlist;
- max object depth, keys, array items và string length;
- numeric range/safe integer, date range và regex có bounded complexity;
- timeout/abort cho async boundary, concurrency/backpressure cho batch;
- reject dangerous keys (
__proto__,constructor,prototype) khi materialize; - không merge raw object vào config/domain bằng
Object.assignmù quáng; - pin/audit schema dependency như code chạy trên attacker-controlled input.
Parse một lần rồi truyền typed value. Parse lại ở mọi layer vừa tốn CPU vừa có nguy cơ mỗi schema dùng coercion/default khác nhau. Với batch lớn, collect số issue có giới hạn; đừng tạo million-error array từ payload tấn công.
Async refine gọi DB cho từng item tạo N+1 và timing side-channel. Batch lookup ở application layer sau structural parse, rồi áp business policy trên normalized IDs.
Runtime tests và type tests
Runtime matrix nên có:
- happy fixture và exact producer fixture cho mỗi version;
- malformed JSON, wrong primitive, missing/present-undefined/null;
- extra field, inherited/dangerous key, oversized/deep/large-array payload;
- invalid date, unsafe integer, BigInt/Map encode-decode round trip;
- webhook signature/replay/idempotency và redacted error snapshot;
- fuzz/property test cho parser không throw ngoài error model đã định nghĩa.
Type tests khóa proof và Input/Output:
type Compare<T> = <Candidate>() => Candidate extends T ? 1 : 2;
type Equal<A, B> = Compare<A> extends Compare<B> ? true : false;
type Expect<T extends true> = T;
type _Wire = Expect<Equal<InputOf<typeof userCodec>, UserWire>>;
type _Domain = Expect<Equal<OutputOf<typeof userCodec>, User>>;
declare const rawId: string;
// @ts-expect-error — raw string chưa qua constructor proof
const id: UserId = rawId;
const okPatch: UserPatch = {};
// @ts-expect-error với exactOptionalPropertyTypes
const badPatch: UserPatch = { displayName: undefined };
Negative type test không thay runtime malformed tests; runtime tests không chứng minh brand/inference không bị widen. Cần cả hai.
Boundary architecture
unknown bytes
→ transport checks (status/content-type/size)
→ schema parse
→ DTO
→ domain mapping + invariants
→ trusted core
Core không nên import schema library nếu chỉ boundary cần nó. Điều này giữ domain test nhanh và tránh vendor type lan khắp codebase.
Lab
Xây boundary cho webhook invoice.paid version 1/2:
- inventory raw bytes, signature, JSON, DTO, domain event và write side effect;
- định nghĩa
Schema<unknown, WebhookDto>và normalize hai version; - dùng opaque
InvoiceId/Money, constructor là nơi duy nhất assertion; - encode/decode
Date+bigintbằng wire representation explicit; - model PATCH-style metadata: omitted/no-op,
null/clear, không dùng undefined; - thêm size/depth/item limits, redaction và error metrics bounded-cardinality;
- test signature, replay, malformed/version/extra field và idempotency;
- so sánh schema-first với codegen cho cùng fixture, ghi decision record.
Acceptance criteria:
- không có
as Webhook/as Domainngoài audited constructor/adapter; - raw input là
unknown, parse và normalize chỉ chạy một lần; - Input/Output codec khác nhau được type test chứng minh;
- DTO/domain/write model không share nhầm representation/quyền ghi;
- v1/v2 về cùng current event, unknown version được quarantine;
- validation/auth/business errors khác taxonomy và không leak payload;
- performance/security limits fail sớm trước expensive validation;
- runtime + negative type tests bắt được contract regression.