1 điểm bởi GN⁺ 4 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • Khi các kiểm tra như if (user.email) nằm rải rác trong mã TypeScript, những điều đã được kiểm tra không còn được lưu trong kiểu, khiến ở phía sau call stack ta cứ tiếp tục nghi ngờ cùng một điều kiện
  • Parser nhận đầu vào thô và trả về kiểu hẹp hơn hoặc thông tin thất bại, giúp phần còn lại của chương trình có thể tin vào những điều đã được xác minh, như EmailAddress
  • Trong TypeScript, vốn dùng hệ thống kiểu cấu trúc, stringEmail không tự nhiên tách biệt, nên ta mô phỏng ranh giới danh nghĩa bằng branded type dựa trên unique symbol và các assertion as được giới hạn
  • Discriminated union như Parsed<T> thể hiện thành công và thất bại ngay trong type signature, nhưng vì không có biểu thức match chuyên dụng nên phải tự viết exhaustive check bằng never
  • Zod, io-ts, valibot có thể tạo parser và kiểu TypeScript cùng từ schema, nhưng kỷ luật parse tại từng ranh giới trước khi xem đầu vào bên ngoài là kiểu domain vẫn thuộc về lập trình viên

Xác thực làm mất thông tin, parse giữ lại trong kiểu

  • Nguyên tắc Parse, don’t validate của Alexis King đặt trọng tâm vào khác biệt giữa validator và parser
    • Validator phán định “giá trị này ổn” rồi chuyển luồng bằng boolean hoặc exception
    • Parser nhận đầu vào thô và tạo ra một kiểu chính xác hơn hoặc trả về lý do thất bại
  • Nếu kiểu vẫn rộng như User.email: string, User.age: number, thì dù isValidUser(user): boolean đã pass, TypeScript cũng không nhớ điều đó
  • Sau đó, trong mã như emailService.send(user.email, ...), user.email vẫn là string thông thường, có thể là chuỗi rỗng, "hello", hay "definitely not an email"
  • Luồng phải kiểm tra lại cùng một điều kiện ở nhiều nơi gần với thứ King gọi là shotgun parsing

API mà chính kiểu là bằng chứng

  • Hình thức mong muốn là type signature chỉ nhận giá trị đã parse, như sendWelcome(user: ValidUser)
  • Với cấu trúc này, trước khi gọi sendWelcome bắt buộc phải đi qua parser, và bên trong hàm không cần kiểm tra lại hay viết if phòng thủ riêng
  • Trong Elm, có thể xử lý đơn giản bằng opaque type và smart constructor, nhưng trong TypeScript cần nhiều cơ chế hơn để đạt hiệu quả tương tự

Tạo ranh giới danh nghĩa bằng branded type

  • TypeScript dùng hệ thống kiểu cấu trúc, nên các kiểu có cùng shape được xem là cùng kiểu
    • stringstring, và không có tính năng tạo kiểu thật sự khác như newtype của Haskell
  • Cách vòng qua mà cộng đồng dùng là branding hoặc tagging
    • Cách đơn giản là một phantom field dạng string literal như { readonly __brand: "Email" }
    • Cách mạnh hơn là dùng unique symbol không export ra ngoài module làm khóa brand
  • Kiểu ví dụ có dạng type Email = string & { readonly [EmailBrand]: true }, type Age = number & { readonly [AgeBrand]: true }
  • Trường brand là marker ở cấp kiểu, không tồn tại lúc runtime, và khiến Email với string được đối xử khác nhau ở compile time
  • Brand chỉ hoạt động một chiều
    • Email có thể gán cho string
    • string thông thường không thể đi thẳng vào Email

Parser chỉ cho phép assertion tại ranh giới tin cậy

  • parseEmail(raw: string): Parsed<Email> trả về thất bại nếu chuỗi không có @, và nếu pass thì tạo branded type bằng raw as Email
  • Assertion as Email là ngoại lệ được cho phép vì parser là ranh giới tin cậy
    • Nếu ở nơi khác trong codebase assertion string thành Email, thiết kế sẽ sụp đổ
    • Có thể đặt parser trong module riêng và xem việc brand assertion xuất hiện bên ngoài nó là bug
  • Parsed<T> trong ví dụ có dạng { kind: "ok"; value: T } | { kind: "err"; error: ParseError }
    • Thất bại không bị ẩn trong exception mà xuất hiện trong type signature
    • Dùng discriminator dạng chuỗi như kind: "ok" | "err" giúp type narrowing hoạt động trung thực hơn khi về sau thêm biến thể
  • Ví dụ parseEmail cố ý rất mỏng; parser email thực tế còn phải xử lý thêm trim, lowercase, xác thực domain, v.v.

Tách đầu vào thô khỏi kiểu domain đáng tin cậy

  • Tách UnvalidatedUserValidUser giúp phân định rõ giá trị đến từ mạng hoặc đầu vào bên ngoài với giá trị có thể tin cậy trong domain
    • UnvalidatedUser đặt id, email, ageunknown
    • ValidUser dùng branded type như UserId, Email, Age
  • Nếu cũng brand UserId, ta có thể tránh lỗi truyền nhầm ID khác như OrderId vào nơi cần UserId
  • parseUser(raw: unknown): Parsed<ValidUser> thu hẹp đầu vào thô theo từng bước
    • Kiểm tra đầu vào có phải object không
    • Kiểm tra sự tồn tại của các field id, email, age
    • Kiểm tra email có phải string không
    • Gọi lần lượt parseUserId, parseEmail, parseAge, và trả về ngay nếu thất bại
    • Nếu tất cả thành công, trả về ValidUser
  • Cách này dài dòng hơn F# hay Elm, nhưng sendWelcome(user: ValidUser) trở nên an toàn thật sự

Những điểm TypeScript gây vướng

  • Ma sát đầu tiên là assertion as Email bên trong parser
    • Trong ngôn ngữ có kiểu danh nghĩa thật, smart constructor có thể trả về kiểu mới mà không cần “nói dối”
    • Brand của TypeScript là marker kiểu tưởng tượng, nên parser phải vượt qua bằng assertion
  • Ma sát thứ hai là exhaustive check
    • Discriminated union của TypeScript rất mạnh trong phong cách này, nhưng không có biểu thức match chuyên dụng
    • Phải tự dùng pattern như const _exhaustive: never = result trong default của switch
    • Nếu thêm biến thể thứ ba vào Parsed, phép gán never sẽ fail và compiler chỉ ra vị trí
  • satisfies có thể được dùng như một escape hatch lịch sự hơn cast
    • const x = { ... } satisfies Config kiểm tra kiểu mà vẫn không mở rộng literal type một cách không cần thiết
  • JSON.parse trả về any, an toàn hơn là annotate ngay thành unknown
    • Nhận theo dạng const raw: unknown = JSON.parse(input), rồi để parser quyết định có phải kiểu domain không
    • JSON.parse không phải validator, mà là bước deserialize biến byte thành giá trị JS

Thư viện như Zod giảm lặp lại

  • Zod, io-ts, valibot cung cấp cùng pattern này theo cách tiện hơn parser viết tay
  • Ví dụ Zod tạo parser và kiểu TypeScript cùng từ một schema
    • z.object({ id: z.number().int(), email: z.string().email().brand<"Email">(), age: z.number().int().min(0).max(150).brand<"Age">() })
    • Lấy kiểu bằng z.infer<typeof ValidUserSchema>
    • ValidUserSchema.safeParse(rawInput) trả về data khi thành công, error khi thất bại
  • .brand() của Zod cũng là tính năng ở cấp kiểu giống brand bằng symbol tự tạo, và không có hành vi runtime
  • Thư viện giúp buộc parser và kiểu vào cùng một định nghĩa để giữ ranh giới dễ hơn, nhưng không thay lập trình viên cưỡng chế kỷ luật phải dùng nó ở mọi ranh giới bên ngoài
  • User đến từ mạng chưa phải là User trong domain cho đến khi được parse, và cần tránh cám dỗ dùng type assertion để lách thông báo lỗi

Đặt bằng chứng vào kiểu, không vào trí nhớ

  • Nguyên tắc nhỏ là “hãy để type system giữ bằng chứng, đừng phó mặc cho trí nhớ con người”
  • Nếu kiểm tra một điều kiện mà không encode kết quả vào kiểu, code về sau dễ giả định rằng việc xác thực đó đã xong
  • Trong TypeScript, nguyên tắc này được triển khai dựa trên ba công cụ
    • Branded type mô phỏng định danh danh nghĩa
    • Discriminated union thể hiện thành công và thất bại
    • Ranh giới nghiêm ngặt giữa unknown của đầu vào bên ngoài và kiểu domain đáng tin cậy
  • Không phải lúc nào biến toàn bộ code thành pipeline parse cũng phù hợp, nhưng nếu cùng một if phòng thủ lặp lại ở nhiều file, đó là tín hiệu rằng thông tin cần xác thực chưa được đưa vào kiểu

1 bình luận

 
Các ý kiến trên Lobste.rs
  • Nếu JavaScript/TypeScript xung đột với phong cách code mong muốn về mặt kỹ thuật và công thái học, có lẽ chỉ cần dùng một trong vô số ngôn ngữ biên dịch sang JS là được chăng
    Haskell, Elm, F# được nhắc đến, và cũng có nhiều ngôn ngữ thuộc nhóm mà tác giả có vẻ muốn dùng hơn như PureScript, js_of_ocaml, Reason, LunarML, v.v. Tác giả thậm chí còn viết bài Why TypeScript Won’t Save You, so sánh thêm với các ngôn ngữ mình ưa thích, và cũng vận hành https://learnelm.dev.
    Hoặc có lẽ bản thân việc so sánh mới là mục đích: cho thấy TypeScript trong nhiều trường hợp là chưa đủ, rồi khuyến khích áp dụng toolchain hay ý tưởng khác

    • Có những ràng buộc như codebase hiện có, mức độ thành thạo một ngôn ngữ cụ thể của đội ngũ hoặc quy định của công ty, ít hỗ trợ/công cụ/quy mô cộng đồng hơn
      Phần lớn mọi người đơn giản là không có quyền lựa chọn hoặc thời gian để chọn ngôn ngữ khác
    • Thường có lẽ là vì đang có một codebase TypeScript lớn, hoặc đang dùng thư viện TypeScript mà các ngôn ngữ khác không có
  • Trong công việc tôi rất thích branded type, nhưng việc không thể tạo Array hay TypedArray chỉ có thể index bằng branded number thật sự gây khó chịu
    TypedArray thậm chí không thể lưu branded number, hay chính xác hơn là cũng không thể đọc chúng ra. Dù có cần một bộ type riêng như IndexArray hay IndexTypedArray đi nữa, tôi vẫn rất muốn có tính năng như vậy

    • Tôi cũng thích branded type, nhưng khi nói chuyện thì ai cũng cho rằng nó không đáng so với công sức bỏ ra
      Nếu dùng branded type cho mọi ID trong một schema cơ sở dữ liệu khá phức tạp, TypeScript sẽ bắt được khi tạo các join hay điều kiện vô lý. Chữ ký hàm cũng rõ ràng hơn, và khó mắc nhiều lỗi hơn
    • Nếu sẵn sàng “nói dối” đủ mạnh, thì vẫn có thể tạo Array chỉ có thể index bằng branded number
      Nếu muốn, với giá trị của TypedArray cũng có thể làm theo cách tương tự
    • Ở chỗ làm, chúng tôi dùng “smart enum” và kiểu mảng tùy chỉnh để có thể viết kiểu như TArray<Foo, MyEnum>. Tuy nhiên đây là chuyện của C++
      Thư viện std của Zig có EnumArray được triển khai bằng comptime. Nó còn cung cấp các chức năng rộng hơn, như dùng enum dày đặc hoặc enum thưa để index, và tính toán indexer đúng tại thời điểm biên dịch.
      Tôi ngày càng thích kiểu định kiểu chính xác như thế này. Nó ngăn được rất nhiều lỗi logic lọt vào codebase ngay từ đầu