+1

Làm thế nào để validate `unknown` trong TypeScript mà không cần Schema Library? 🛡️

Hoi hoi! 👋

Mình là @nyaomaru, một frontend engineer mê gamba. 😸🦐

Gần đây, mình nghĩ về một vấn đề rất phổ biến trong TypeScript.

Giả sử một giá trị đến từ:

  • API response
  • JSON.parse
  • local storage
  • message payload
  • third-party code

Trước khi thực sự kiểm tra nó, giá trị đó nên có type gì?

Có lẽ là thế này:

declare const input: unknown;

Phần đó thì đơn giản.

Câu hỏi thú vị là chuyện gì xảy ra tiếp theo.

Làm thế nào để biến unknown thành thứ gì đó hữu ích một cách an toàn?

Và liệu lần nào chúng ta cũng thật sự cần một schema library đầy đủ?

Cùng xem nhé! 👀

https://is-kit.dev/guides/validate-unknown-without-schema-library


🧱 unknown đang làm đúng việc của nó

Giả sử chúng ta có:

declare const input: unknown;

input.name;
// Error

TypeScript không cho phép truy cập name.

Ban đầu điều này có thể hơi khó chịu.

Nhưng thực ra TypeScript đang nói với chúng ta một điều rất quan trọng:

Chúng ta có một giá trị.

Nhưng vẫn chưa biết nó là gì.

Đó chính xác là ý nghĩa mà một giá trị từ untrusted boundary nên có.

Một HTTP response không tự động trở thành User chỉ vì application của chúng ta mong đợi User.

JSON đã parse cũng vậy.

Vì thế mình thích nghĩ về unknown như sau:

unknown không phải là thiếu thông tin về type.

Nó là mô tả trung thực của một giá trị mà chúng ta chưa verify.

Vấn đề không phải là unknown.

Vấn đề là làm thế nào để thiết lập trust.


😈 Lối tắt dễ nhất: as User

Có một cách thoát rất ngắn:

type User = {
  id: string;
  name: string;
};

declare const input: unknown;

const user = input as User;

user.name.toUpperCase();

TypeScript hài lòng.

Tuyệt! 🎉

Nhưng không có gì thực sự được kiểm tra.

Đoạn này vẫn compile được:

const input = {
  id: "user-1",
  name: 123,
} as unknown;

const user = input as User;

user.name.toUpperCase();
// 💥 Runtime error

as User chỉ thay đổi điều compiler tin tưởng.

Nó không inspect runtime value.
Không thêm property bị thiếu.
Không biến 123 thành string.

Vì vậy nếu runtime trust là quan trọng, type assertion không phải validation.

Chúng ta cần một runtime check thực sự.


🛡️ Type Guard kết nối Runtime với TypeScript

Bắt đầu từ một ví dụ nhỏ.

Với is-kit, ta có thể tạo guard như sau:

import { isString, oneOfValues, struct } from "is-kit";

const isUser = struct({
  id: isString,
  name: isString,
  role: oneOfValues("admin", "member"),
});

Bây giờ có thể truyền unknown trực tiếp vào:

declare const input: unknown;

if (isUser(input)) {
  input.name.toUpperCase();

  // input:
  // Readonly<{
  //   id: string;
  //   name: string;
  //   role: "admin" | "member";
  // }>
}

Đây là phần mình thích nhất.

Cùng một check làm hai việc:

runtime
→ giá trị này có thực sự match không?

TypeScript
→ sau khi check pass, giá trị này có type gì?

Không cần schema definition riêng và cũng không cần type assertion.

Chỉ cần compose những runtime predicate nhỏ.


🧩 Shape có thể lớn dần một cách tự nhiên

Payload thực tế thường phức tạp hơn một chút.

Có thể user có:

  • profile optional
  • bio nullable
  • danh sách tags

Ta có thể tiếp tục compose guards:

import {
  arrayOf,
  isString,
  nullable,
  oneOfValues,
  optionalKey,
  struct,
} from "is-kit";

const isProfile = struct({
  displayName: isString,
  bio: optionalKey(nullable(isString)),
});

const isUser = struct({
  id: isString,
  role: oneOfValues("admin", "member"),
  profile: optionalKey(isProfile),
  tags: arrayOf(isString),
});

Sau đó:

isUser({
  id: "user-1",
  role: "member",
  tags: ["typescript", "guards"],
});
// true

Nhưng:

isUser({
  id: "user-1",
  role: "owner",
  tags: ["typescript"],
});
// false

Mỗi phần vẫn chỉ là một guard.

Nghĩa là ta có thể test riêng, reuse ở nơi khác, hoặc compose thành cấu trúc lớn hơn.

Ở đây cũng có một khác biệt quan trọng:

optionalKey(...)

nghĩa là:

property có thể không tồn tại

trong khi:

nullable(...)

nghĩa là:

value có thể là null

Đây là hai runtime contract khác nhau, nên mình thích giữ chúng tách biệt.


📦 safeParse thêm gì vào?

Một guard trực tiếp thường đã đủ.

if (isUser(input)) {
  renderUser(input);
}

Nhưng đôi khi mình muốn chính validation result tiếp tục đi qua chương trình.

Ví dụ:

const result = safeParse(isUser, input);

if (!result.valid) {
  return { status: 400 as const };
}

return {
  status: 200 as const,
  user: result.value,
};

safeParse trả về một tagged result nhỏ:

{
  valid: true,
  value,
}

hoặc:

{
  valid: false,
}

Vì vậy TypeScript có thể narrowing bằng một branch bình thường.

Điểm quan trọng là safeParse không làm gì.

Nó không:

  • clone value
  • coercion value
  • tạo object mới
  • biến invalid data thành valid data

Khi thành công, nó trả về chính value đó sau khi guard đã chấp nhận nó.

Mình nghĩ về sự khác biệt như sau:

guard
→ boolean + narrowing

safeParse
→ tagged result + validated value

Cùng một runtime rule.

Chỉ khác control-flow shape.


🧃 Còn JSON thì sao?

Đây là một trong những boundary phổ biến nhất.

const value = JSON.parse(body);

Điểm khó chịu là JSON.parse trả về any.

Vì vậy giá trị unsafe có thể lan ra chương trình trước khi ta validate nó.

Thay vào đó, ta có thể giữ boundary thật nhỏ:

import { safeJsonParse } from "is-kit";

declare const body: string;

const result = safeJsonParse(body, isUser);

if (!result.valid) {
  return { status: 400 as const };
}

renderUser(result.value);

Về mặt ý tưởng, flow là:

JSON text
   ↓
decode
   ↓
unknown
   ↓
guard
   ↓
validated value

JSON không hợp lệ và guard failure đều cho:

{
  valid: false,
}

Và một lần nữa, không có coercion nào chỉ để làm value pass.

Mình thích cách này vì phần unsafe được giữ ngay tại decode boundary.


🚪 Có nên cho phép extra keys không?

Có một lựa chọn thiết kế nhỏ khác ở object boundary.

Giả sử ta mong đợi:

{
  username: string;
  password: string;
}

Thế object này có nên được chấp nhận không?

{
  username: "Neko",
  password: "secret",
  admin: true,
}

Với struct, extra keys mặc định được cho phép.

Điều này thường hữu ích với API response, vì server có thể thêm field mới mà không làm client cũ bị break.

Nhưng đôi khi ta thực sự muốn closed object shape.

Khi đó có thể dùng:

const isCredentials = struct(
  {
    username: isString,
    password: isString,
  },
  { exact: true },
);

Bây giờ:

isCredentials({
  username: "Neko",
  password: "secret",
});
// true

trong khi:

isCredentials({
  username: "Neko",
  password: "secret",
  admin: true,
});
// false

Điểm quan trọng không phải mode nào luôn tốt hơn.

Mà boundary phải thể hiện đúng contract mà chúng ta thực sự muốn.


🤔 Vậy vẫn cần Schema Library chứ?

Có.

Đôi khi là hoàn toàn cần thiết.

Type guard toolkit và schema library có một chút overlap, nhưng không cố giải quyết chính xác cùng một bài toán.

Ví dụ, schema library có lẽ là lựa chọn tốt hơn khi cần:

  • structured validation errors với field paths
  • nhiều validation issues cùng lúc
  • coercion
  • default values
  • transformations
  • schema-generated types hoặc external artifacts
  • integration sâu với form hoặc API framework

Đó là những validation workflow lớn hơn.

is-kit đặt một câu hỏi hẹp hơn:

Value này có thỏa runtime predicate này không?

Nếu có, TypeScript có thể safely narrow nó thành type nào?

Đôi khi model hẹp hơn này chính xác là thứ mình cần.

Ví dụ:

Approach Phù hợp với
Inline typeof checks Một condition local nhỏ
Type guards Boolean validation và narrowing có thể reuse
Schema library Rich validation, transformation và schema workflows

Vì vậy mình không nghĩ câu hỏi nên là:

Type guards hay schema libraries — cái nào thắng?

Câu hỏi hữu ích hơn là:

Validation model nhỏ nhất nào đủ để giải quyết boundary này?


🎯 Phần quan trọng nhất

Ta bắt đầu với một value đơn giản:

declare const input: unknown;

Lối tắt hấp dẫn là:

const user = input as User;

Nhưng nó chỉ thay đổi điều TypeScript tin.

Nó không thiết lập runtime trust.

Runtime guard cho ta một flow hữu ích hơn nhiều:

unknown
   ↓
runtime check
   ↓
narrowed value

Và khi muốn một result-oriented API:

unknown
   ↓
guard
   ↓
safeParse
   ↓
{ valid: true, value }
hoặc
{ valid: false }

Không có hidden conversion.

Không có assertion giả vờ rằng check đã xảy ra.

Chỉ là những runtime rule nhỏ mà TypeScript cũng hiểu được. 😸

Nếu bạn muốn xem full guide, gồm JSON boundaries, exact objects và custom domain refinements:

https://is-kit.dev/guides/validate-unknown-without-schema-library

Và nếu muốn khám phá is-kit:

https://github.com/nyaomaru/is-kit

Nếu thấy hữu ích, một ⭐ trên GitHub luôn rất được chào đón!

Cảm ơn bạn đã đọc! 🙌


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í