posts đơn giản. Nhưng trong thực tế, một bài viết hiếm khi đứng một mình. Nó có tác giả, có tags phân loại, có ảnh hoặc video đính kèm.
Khi bạn query posts kèm theo các bảng liên quan, kết quả trả về không còn là một flat object nữa — nó là một cây dữ liệu lồng nhau. Và type system của bạn cũng phải phản ánh điều đó.
Câu hỏi thực tế xuất hiện ngay: type của một bài viết kèm author, tags, và media trông như thế nào? Viết tay hay để Drizzle infer? Nếu để Drizzle infer thì infer bằng cách nào?
Bài này trả lời từng câu.
Bức tranh tổng thể
Chúng ta sẽ làm việc với bốn bảng:posts— bài viết (đã có từ bài trước)authors— tác giảtags— danh sách tagpost_tags— bảng trung gian, nhiều-nhiều giữapostsvàtagspost_media— ảnh/video đính kèm, quan hệ một-nhiều vớiposts
Bước 1: Định nghĩa schema cho tất cả các bảng
src/db/schema.ts
with:
src/db/relations.ts
relations() không tạo ra foreign key trong DB — đó là việc của migration. Nó chỉ nói với Drizzle query builder: “khi tôi dùng with, hãy join theo cách này.”
Bước 2: Infer base row type cho từng bảng
src/types/post.ts
Bước 3: Infer type cho relational query
Đây là phần mới. Khi bạn dùngdb.query.posts.findMany({ with: { author: true, ... } }), kết quả trả về có shape lồng nhau. Để có được type đó mà không phải tự mô tả lại, có hai cách.
Cách 1: Infer từ chính câu query (đơn giản, thực tiễn)
src/db/queries/post.ts
Awaited<> unwrap Promise, NonNullable<> loại bỏ undefined (vì findFirst có thể trả về undefined). Kết quả là một type tự động mô tả đúng shape của dữ liệu trả về, bao gồm cả nested relations.
Shape thực tế của PostWithRelations sẽ là:
Cách 2: Dùng BuildQueryResult của Drizzle (linh hoạt hơn)
Cách 1 yêu cầu bạn phải có một query cụ thể để infer. Nếu bạn muốn định nghĩa type độc lập với query, Drizzle cung cấp utility type cho điều đó:
Bước 4: Tạo app-level type từ dữ liệu lồng nhau
PostWithRelations vẫn còn “thô” — nó phản ánh DB shape với nullable fields và junction table. Ở tầng app, bạn thường muốn một type gọn hơn:
src/types/post.ts
Post ở đây đã khác bài trước: nó embed luôn author, tags, media thay vì chỉ lưu authorId. Đây là “domain object đã được resolve” — object bạn dùng trong component và server action, không cần đi query thêm gì nữa.
Bước 5: Mapper function cho dữ liệu lồng nhau
src/lib/mappers/post.ts
postTags trong DB là mảng của junction row { postId, tagId, tag: {...} }. Mapper rowToTag “làm phẳng” nó thành Tag gọn hơn — tầng app không cần quan tâm đến postId hay tagId trong junction row.
Cấu trúc file đề xuất
relations.ts khỏi schema.ts giúp tránh circular import khi schema phức tạp — và giúp file schema không bị phình to.
Nguyên tắc thực tiễn
Nên:- Infer type cho relational query từ chính câu query (
Awaited<typeof ...>) — đơn giản nhất - Dùng
BuildQueryResultkhi cần type độc lập với query cụ thể - Mapper function để “làm phẳng” junction table và xử lý null một lần
- App-level type embed relation trực tiếp (
tags: Tag[]) thay vì chỉ giữtagIds
- Dùng
PostWithRelationstrực tiếp trong component — nullable field và junction row sẽ lan vào UI - Viết tay type cho nested relation — dễ lệch khi schema thay đổi
- Một mapper “God function” xử lý mọi variant — nên tách
rowToAuthor,rowToTag,rowToPostriêng biệt
Tóm tắt
| Type | Nguồn gốc | Dùng ở đâu |
|---|---|---|
PostRow, AuthorRow, TagRow… | $inferSelect | DB layer, mapper input |
PostWithRelations | Awaited<typeof query> | Mapper input |
Post | Compose từ PostRow + embed relations | App logic, component |
Author | Omit + override từ AuthorRow | Embed trong Post |
Tag | Pick<TagRow, ...> | Embed trong Post |
PostMedia | Alias của PostMediaRow | Embed trong Post |
comments, reactions, hay phân quyền theo author — pattern này vẫn scale tốt vì mỗi tầng có trách nhiệm rõ ràng và type luôn có một nguồn gốc xác định.