Đừng xác thực, hãy parse — trong một ngôn ngữ không như ý như TypeScript
(cekrem.github.io)- 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,
stringvàEmailkhô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ênunique symbolvà các assertionasđượ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ứcmatchchuyên dụng nên phải tự viết exhaustive check bằngnever - 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.emailvẫ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
sendWelcomebắ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ếtifphò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
stringlàstring, và không có tính năng tạo kiểu thật sự khác nhưnewtypecủ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 symbolkhông export ra ngoài module làm khóa brand
- Cách đơn giản là một phantom field dạng string literal như
- 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
Emailvớistringđược đối xử khác nhau ở compile time - Brand chỉ hoạt động một chiều
Emailcó thể gán chostringstringthông thường không thể đi thẳng vàoEmail
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ằngraw as Email- Assertion
as Emaillà 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
stringthànhEmail, 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
- Nếu ở nơi khác trong codebase assertion
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ụ
parseEmailcố ý 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
UnvalidatedUservàValidUsergiú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 domainUnvalidatedUserđặtid,email,agelàunknownValidUserdù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ưOrderIdvào nơi cầnUserId 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
emailcó 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 Emailbê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
matchchuyên dụng - Phải tự dùng pattern như
const _exhaustive: never = resulttrongdefaultcủaswitch - Nếu thêm biến thể thứ ba vào
Parsed, phép gánneversẽ fail và compiler chỉ ra vị trí
- Discriminated union của TypeScript rất mạnh trong phong cách này, nhưng không có biểu thức
satisfiescó thể được dùng như một escape hatch lịch sự hơn castconst x = { ... } satisfies Configkiểm tra kiểu mà vẫn không mở rộng literal type một cách không cần thiết
- Vì
JSON.parsetrả vềany, an toàn hơn là annotate ngay thànhunknown- 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.parsekhông phải validator, mà là bước deserialize biến byte thành giá trị JS
- Nhận theo dạng
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àUsertrong 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
unknowncủ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
ifphò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
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
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
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 muốn, với giá trị của TypedArray cũng có thể làm theo cách tương tự
TArray<Foo, MyEnum>. Tuy nhiên đây là chuyện của C++Thư viện
stdcủa Zig có EnumArray được triển khai bằngcomptime. 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