Skip to main content
Hai bài trước đã xây dựng xong type system cho một bảng 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 tag
  • post_tags — bảng trung gian, nhiều-nhiều giữa poststags
  • post_media — ảnh/video đính kèm, quan hệ một-nhiều với posts
Quan hệ giữa chúng:

Bước 1: Định nghĩa schema cho tất cả các bảng

src/db/schema.ts
Sau đó khai báo relations — đây là bước bắt buộc để Drizzle hiểu được cấu trúc khi dùng relational query với 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
Cùng nguyên tắc từ bài trước: schema là nguồn sự thật, type được infer từ đó — không viết tay.

Bước 3: Infer type cho relational query

Đây là phần mới. Khi bạn dùng db.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à:
TypeScript infer ra toàn bộ — bạn không cần viết một dòng nào trong đống type lồng nhau này.

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 đó:
Sau đó dùng như sau:
Cách này tách type ra khỏi query cụ thể — hữu ích khi cùng một type được dùng ở nhiều nơi với nhiều query khác nhau nhưng cùng shape. Tuy nhiên nó phức tạp hơn cách 1, nên chỉ nên dùng khi thực sự cần.

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
Lưu ý 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
Điểm quan trọng: 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

Tách 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 BuildQueryResult khi 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
Tránh:
  • Dùng PostWithRelations trự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, rowToPost riêng biệt

Tóm tắt

Ba bài trong series này đi theo một hướng nhất quán: schema là nguồn sự thật duy nhất, type được infer hoặc derive từ đó, và mapping function là nơi duy nhất xử lý sự khác biệt giữa DB shape và app shape. Không viết tay cái gì mà Drizzle đã biết, không để DB shape rò rỉ lên tầng UI. Khi domain tiếp tục lớn — thêm 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.
Last modified on June 11, 2026