0

`is-kit` đã đạt 50 GitHub Stars — Đây là cách chúng tôi sử dụng nó trong production

Xin chào!

Tôi là @nyaomaru, một frontend engineer đang cố gắng giảm cân. 🐖🙀

Tôi duy trì một thư viện TypeScript type guard có tên là is-kit.

Tôi dành một lượng thời gian hơi vô lý để tự hỏi:

“Nhưng nếu giá trị này thực ra là unknown thì sao???”

Gần đây, is-kit đã đạt 50 GitHub Stars 🎉🎉🎉

Một Star không phải là benchmark.

Và 50 Stars cũng không tự nhiên biến một thư viện thành production-ready.

Nhưng mỗi Star vẫn có ý nghĩa rằng:

“Có ai đó thấy ý tưởng này hữu ích.”

Điều đó khiến tôi rất vui!!

Mỗi Star lại cho tôi thêm động lực để tiếp tục cải thiện thư viện!!!!

Ngoài ra, lần này tôi còn có một điều cụ thể hơn muốn chia sẻ:

is-kit hiện đang được sử dụng trong một ứng dụng TypeScript production phục vụ hơn 100.000 người dùng. 🚀

Trong bài viết này, tôi sẽ chia sẻ:

  • Vấn đề ban đầu là gì
  • Chúng tôi đã đưa is-kit vào như thế nào
  • Điều gì thực sự thay đổi
  • Hiện tại nó đang được sử dụng ở đâu
  • Những lợi ích thực tế của nó là gì

Bắt đầu thôi!


📋 Vấn đề không phải là “validation”

Ứng dụng đã có rất nhiều kiểm tra nhỏ như thế này 👇

typeof value === "string";
typeof value === "number";
value === null || value === undefined;

Ngoài ra còn có các type guard do ứng dụng tự định nghĩa dành cho:

  • HTTP client errors
  • Status codes
  • Literal unions
  • Arrays
  • Plain objects
  • Giá trị đến từ JSON hoặc API responses

Mỗi phép kiểm tra đều hoàn toàn hợp lý khi đứng riêng lẻ.

Vấn đề xuất hiện khi chúng bắt đầu được lặp đi lặp lại.

Ví dụ, một số error guard có cấu trúc gần như giống hệt nhau.

type HttpClientError<T = unknown> = Error & {
  isHttpClientError: true;
  response?: {
    status: number;
    data: T;
  };
};

function isUnauthorizedError(error: unknown): error is HttpClientError {
  return (
    !!error &&
    (error as HttpClientError).isHttpClientError === true &&
    (error as HttpClientError).response?.status === 401
  );
}

function isValidationError(error: unknown): error is HttpClientError {
  return (
    !!error &&
    (error as HttpClientError).isHttpClientError === true &&
    (error as HttpClientError).response?.status === 422
  );
}

Đoạn code này vẫn hoạt động.

Nhưng nó có ba vấn đề thực tế:

  1. Cùng một base check bị lặp lại
  2. Assertion cast xuất hiện trong mỗi guard
  3. Mỗi khi thêm một status mới, chúng ta lại phải tạo thêm một bản sao tương tự

Code không hề bị hỏng.

Nó chỉ đang cần một abstraction có thể tái sử dụng. 🔧


🏃‍♂️ Pattern được sử dụng trong production

Chúng tôi thay các phép kiểm tra lặp lại bằng những guard nhỏ có thể kết hợp với nhau.

Dưới đây là phiên bản đã loại bỏ các chi tiết liên quan đến business từ pattern thực tế trong production.

import { define, equalsKey, or } from "is-kit";

type HttpClientError<T = unknown> = Error & {
  isHttpClientError: true;
  code?: string;
  response?: {
    status: number;
    data: T;
  };
};

const isHttpClientError = define<HttpClientError>((value) =>
  equalsKey("isHttpClientError", true)(value),
);

const isHttpErrorWithStatus = (status: number) =>
  define<HttpClientError>(
    (value) => isHttpClientError(value) && value.response?.status === status,
  );

export const isUnauthorizedError = isHttpErrorWithStatus(401);

export const isValidationError = isHttpErrorWithStatus(422);

const hasTimeoutCode = define<HttpClientError>(
  (value) => isHttpClientError(value) && value.code === "TIMEOUT",
);

const hasTimeoutMessage = define<HttpClientError>(
  (value) => isHttpClientError(value) && value.message.includes("timed out"),
);

export const isTimeoutError = or(hasTimeoutCode, hasTimeoutMessage);

Có một vài điểm quan trọng ở đây.

define

define<T> biến một runtime boolean check thành một predicate có thể tái sử dụng.

const isHttpErrorWithStatus = (status: number) =>
  define<HttpClientError>(...);

Trách nhiệm vẫn thuộc về chúng ta.

Runtime check phải thực sự chứng minh rằng giá trị đó là T.

is-kit không thể biến một predicate sai thành predicate đúng.

Nhưng nó giúp custom guard có một cấu trúc nhất quán.

equalsKey

Base error ở đây không phải là JSON hoặc plain object thông thường.

Nó là một Error instance có một marker property.

Vì vậy, plain-object schema không phải abstraction phù hợp trong trường hợp này.

equalsKey("isHttpClientError", true)

Nó biểu đạt chính xác điều chúng ta cần:

“Giá trị này có key này, và giá trị của key đó chính xác là true.”

or

Một timeout có thể được phát hiện theo nhiều cách.

Thay vì tạo thêm một conditional lớn, chúng ta kết hợp hai guard có thể tái sử dụng.

const isTimeoutError = or(hasTimeoutCode, hasTimeoutMessage);

Đây chính là ý tưởng cốt lõi của is-kit:

Tạo các guard nhỏ. Sau đó kết hợp chúng.


✨ Điều gì thực sự thay đổi?

Lần refactor đầu tiên khi áp dụng is-kit không đơn giản chỉ là:

pnpm add is-kit

Nó đã thay đổi cấu trúc của toàn bộ guard layer.

Kết quả có thể quan sát Thay đổi
Error guards 7 module riêng biệt → 1 shared module
Adoption diff Thêm 335 dòng, xoá 584 dòng
Net diff Giảm tổng cộng 249 dòng
Direct imports hiện tại is-kit được giới hạn trong 7 app helper modules
Phạm vi sử dụng hiện tại Các helper này được sử dụng bởi 39 non-test source files

Diff này bao gồm cả các test được viết lại và helper adapters.

Vì vậy, 249 dòng ít hơn không có nghĩa là thư viện đã “ma thuật” xoá code.

Đó chỉ đơn giản là kết quả đo được từ lần consolidation cụ thể đó.

Điều quan trọng hơn là cấu trúc.

is-kit primitives
        ↓
app guard helpers
        ↓
features, routes, services và UI

Ứng dụng production không import is-kit trực tiếp trong mọi component.

Thay vào đó, phần lớn call site sử dụng helper do chính ứng dụng sở hữu.


🤔 Tại sao lại giữ một boundary riêng cho ứng dụng?

Đối với primitive guard, ứng dụng wrap hoặc re-export các guard từ thư viện 👇

import {
  isNumber as isFiniteNumberGuard,
  isNumberPrimitive,
  isString as isStringGuard,
} from "is-kit";

export const isString = isStringGuard;
export const isNumber = isNumberPrimitive;
export const isFiniteNumber = isFiniteNumberGuard;

Điều này trông giống như một chi tiết nhỏ, nhưng thực ra là một quyết định thiết kế quan trọng.

Trong JavaScript, “number” có thể mang nhiều ý nghĩa hữu ích khác nhau.

typeof NaN === "number";
typeof Infinity === "number";

Trong ứng dụng:

  • isNumber tuân theo primitive typeof semantics
  • isFiniteNumber từ chối NaNInfinity

Ứng dụng sở hữu những cái tên và semantics đó.

is-kit cung cấp implementation có thể tái sử dụng.

Boundary này cũng có nghĩa là:

  • Call site không phụ thuộc vào naming decision của thư viện
  • Semantics được giữ nhất quán trong toàn bộ ứng dụng
  • Nếu cần migration trong tương lai, chúng ta có một điểm bắt đầu rõ ràng

Đây là cách tôi thích đưa những thư viện nhỏ vào các ứng dụng lớn.

Đặt chúng phía sau vocabulary riêng của ứng dụng.


😎 Những pattern thực tế khác

HTTP error guard là ví dụ lớn nhất, nhưng không phải trường hợp duy nhất.

Arrays

import { arrayOf, isNumberPrimitive } from "is-kit";

export const isNumberArray = arrayOf(isNumberPrimitive);

Thay cho:

const isNumberArray = (value: unknown): value is number[] =>
  Array.isArray(value) &&
  value.every((item): item is number => typeof item === "number");

Literal unions

import { oneOfValues } from "is-kit";

const VIEW_MODES = ["compact", "comfortable"] as const;

const isViewMode = oneOfValues(VIEW_MODES);

declare const input: unknown;

if (isViewMode(input)) {
  // "compact" | "comfortable"
  input;
}

Nullish values

import { isNull, isUndefined, or } from "is-kit";

export const isNullish = or(isNull, isUndefined);

Vì đây là một function, chúng ta có thể tái sử dụng trực tiếp.

const definedItems = items.filter((item) => !isNullish(item));

Ứng dụng hiện tại sử dụng cùng ý tưởng này cho:

  • Error branching
  • Giá trị đến từ JSON và API
  • Lọc nullable collections
  • Literal-value checks
  • UI values có thể là string hoặc các renderable values khác

Đây là hình thức sử dụng thực tế trong production.

Không phải một schema khổng lồ,

mà là rất nhiều quyết định nhỏ tại các điểm control flow thông thường.


🚀 Những lợi ích thực tế

Sau khi sử dụng is-kit trong ứng dụng, các lợi ích trở nên rõ ràng hơn.

1. Có thể áp dụng dần dần

Chúng tôi không cần thiết kế lại data layer.

Một phép kiểm tra như:

typeof value === "string";

có thể trở thành:

isString(value);

Và sau đó, nếu việc tái sử dụng trở nên hữu ích:

values.filter(isString);

2. Ít assertion cast hơn

Các error guard cũ thường xuyên sử dụng:

error as HttpClientError;

Trong phiên bản được compose, chúng ta narrow type một lần rồi truy cập giá trị đã được narrow như bình thường:

isHttpClientError(value) && value.response?.status === status;

3. Chia sẻ runtime semantics

Những câu hỏi như sau giờ đây có câu trả lời rõ ràng:

  • “number” có bao gồm NaN không?
  • Object check này có chấp nhận class instance không?
  • Field này là optional, nullable hay cả hai?
  • Hai giá trị được so sánh bằng semantics của === hay Object.is?

Lợi ích không chỉ nằm ở cú pháp ngắn hơn.

Mà là giảm số lượng những câu trả lời “hơi khác nhau một chút” nằm rải rác trong codebase.

4. TypeScript control flow bình thường

Kết quả vẫn chỉ là một function.

if (isValidationError(error)) {
  error.response?.data;
}

Không cần parse result.

Không cần truyền schema object xuyên suốt ứng dụng.

Điều đó khiến các guard dễ sử dụng trong:

  • if
  • filter
  • event handlers
  • error boundaries
  • utility functions

5. Dependency surface nhỏ

is-kit không có runtime dependency.

Điều đó không có nghĩa là bundle cost bằng 0.

Nó có nghĩa là khi thêm is-kit, chúng ta không kéo theo cả một cây transitive runtime dependencies.


👮 Nó đã trở thành một rule của team

Một dấu hiệu cho thấy library đã thực sự được adoption là khi nó không còn chỉ là sở thích cá nhân.

Production repository hiện có contributor rule sau:

Khi kết hợp các guard của is-kit, hãy ưu tiên or, and, andAll, nullish và các combinator liên quan thay vì tự xây lại cùng một composition bằng native operator.

Ví dụ:

const isTextOrNumber = or(isString, isNumberPrimitive);

thay vì:

const isTextOrNumber = (value: unknown) =>
  isString(value) || isNumberPrimitive(value);

Cả hai đều có thể trả về cùng một boolean.

Nhưng phiên bản đầu tiên là một guard có tên, có thể tái sử dụng, truyền đi và tiếp tục compose.

Rule này cũng được áp dụng cho các coding agent làm việc trong repository.

Điều này khá quan trọng.

Một tool chưa thực sự được adoption nếu mỗi contributor — dù là con người hay AI — đều tự tạo ra một style khác nhau.


✖️ Những điều chúng tôi không thể khẳng định

Tôi muốn cẩn thận ở đây.

Chúng tôi không thực hiện controlled study để chứng minh rằng is-kit:

  • Cải thiện runtime performance
  • Giảm production incidents
  • Làm mọi validation task trở nên dễ hơn

Vì vậy tôi sẽ không đưa ra những tuyên bố đó.

Những thay đổi mà chúng tôi thực sự có thể quan sát được là:

  • Các guard lặp lại đã được consolidate
  • Những check phụ thuộc nhiều vào assertion trở thành các predicate có thể compose
  • Primitive semantics được quản lý tập trung
  • Application code có các narrowing function có thể tái sử dụng
  • Pattern này trở thành một phần của repository guidelines

Vì vậy, đây chủ yếu là cải thiện về maintainability và type safety. 🏋️‍♂️


👀 Tại sao không dùng Schema Library?

Đối với những call site này, chúng tôi không cần:

  • Validation error tree chi tiết
  • Data transformations
  • Schema-first model

Điều chúng tôi cần là:

“Giá trị unknown này có thể an toàn đi vào branch này không?”

Đây chính xác là trường hợp type guard phù hợp.

Đối với form, API contract hoặc những trường hợp cần validation error chi tiết, một schema library như Zod vẫn có thể là công cụ phù hợp hơn.

Chúng giải quyết những vấn đề khác nhau.


🎯 50 Stars có ý nghĩa gì với tôi?

So với những TypeScript library lớn nhất, 50 Stars là một con số nhỏ.

Nhưng Open Source không chỉ bắt đầu có ý nghĩa sau khi đạt hàng nghìn Stars.

Đối với tôi, milestone này có nghĩa là:

  • Những người bên ngoài project hiểu được ý tưởng
  • API hữu ích ngoài các toy example
  • Library đang giải quyết một vấn đề maintenance thực tế
  • Vẫn còn rất nhiều thứ có thể cải thiện

Và việc nó được sử dụng trong một ứng dụng production cũng khiến milestone này có thêm một chút trọng lượng.

is-kit không chỉ đang nhận Stars.

Hiện tại, nó đang giúp code thực tế trong ứng dụng trả lời câu hỏi:

“Giá trị này là gì, và TypeScript có thể tin tưởng nó không?”

Cảm ơn tất cả những ai đã Star, thử sử dụng, report issue hoặc đơn giản chỉ ghé qua repository.

Nếu những type guard nhỏ và có thể compose phù hợp với cách bạn viết TypeScript, hãy thử is-kit 👇

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


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í