ข้ามไปยังเนื้อหา

Schemas & References

content คือข้อมูลที่อยู่ นอก โค้ดของเรา ทั้งไฟล์ Markdown ที่แก้ด้วยมือ, JSON จาก CMS ไม่มีอะไรกันการพิมพ์ผิดได้เลย ทั้ง title ที่หาย, pubDate ที่เขียนเป็น "soon", หรือ tag ที่ควรเป็น array แต่กลายเป็น string ถ้าไม่มี validation ความผิดพลาดพวกนี้จะโผล่มาเป็น runtime error งง ๆ ลึก ๆ ใน template หรือแย่กว่านั้นคือหน้าเพจพังเงียบ ๆ

schema ย้ายการตรวจนั้นมาไว้ที่ content boundary เมื่อ Astro โหลด collection จะ validate ทุก entry กับ schema ก่อน ที่หน้าเพจจะเห็น entry ที่ผิดจะทำให้ build ล้มพร้อมข้อความชัดเจน (“pubDate ต้องเป็น date”) ไม่ใช่ crash แบบปริศนา และ content ที่ validate แล้วยังเป็น content ที่มี type ด้วย ที่เป็นผลตอบแทนอย่างที่สอง

Astro ใช้ Zod ทำ schema เราเอา Zod มาจาก astro/zod แล้วอธิบายโครงสร้างของ frontmatter ทีละ field:

src/content.config.ts
import { defineCollection } from 'astro:content';
import { z } from 'astro/zod';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),
schema: z.object({
title: z.string().max(80),
description: z.string().optional(), // may be omitted
pubDate: z.coerce.date(), // "2026-01-01" becomes a Date
draft: z.boolean().default(false), // fills in if absent
tags: z.array(z.string()).default([]),
status: z.enum(['draft', 'published', 'archived']),
}),
});
export const collections = { blog };

ชิ้นส่วนที่ใช้บ่อย:

  • type — z.string(), z.number(), z.boolean(), z.array(...), z.enum([...])
  • .optional() — field อาจหายได้ type จะกลายเป็น T | undefined
  • .default(value) — ถ้า field ไม่มี ให้เติมค่านี้ (โค้ดปลายทางจะได้ไม่ต้องเจอ undefined)
  • z.coerce.date() — แปลง date string จาก frontmatter เป็น Date จริง

refinement อย่าง .max(80) หรือ .url() ช่วยบังคับ constraint จริง ๆ ทั้งความยาว title, URL ที่ถูกต้อง content ที่ผิดจึงถูกจับได้ตอน build

เพราะ schema อธิบาย frontmatter ครบถ้วน Astro จึง generate TypeScript type จาก schema ให้ พอเราพิมพ์ post.data. editor ก็ autocomplete title, pubDate, tags ให้ พร้อม type ที่ถูกต้อง post.data.pubDate เป็น Date ส่วน post.data.status เป็น union 'draft' | 'published' | 'archived' เราไม่ต้องเขียน interface Post เองเลย schema คือ แหล่งความจริงเดียวทั้งสำหรับ validation และ type

flowchart TB
  schema["Zod schema ใน content.config.ts"] --> validate["validate ทุก entry ตอน build"]
  schema --> types["generate TypeScript type"]
  validate --> safe["content ผิดทำให้ build ล้มตั้งแต่เนิ่น"]
  types --> dx["autocomplete และ type-check entry.data"]
schema เดียว ได้สองผลตอบแทน

content แทบไม่เคยแบน โพสต์บล็อกหนึ่งอันมี author, หน้า doc อยู่ในหมวดหนึ่ง แทนที่จะ copy ข้อมูล author ไปทุกโพสต์ เราเก็บ collection authors ไว้ แล้วให้แต่ละโพสต์ reference author ด้วย id — helper reference() ของ Astro (จาก astro:content) เขียนความเชื่อมโยงนั้นและ validate ให้:

src/content.config.ts
import { defineCollection, reference } from 'astro:content';
import { z } from 'astro/zod';
import { glob, file } from 'astro/loaders';
const authors = defineCollection({
loader: file('./src/data/authors.json'),
schema: z.object({ name: z.string(), bio: z.string() }),
});
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),
schema: z.object({
title: z.string(),
author: reference('authors'), // must match an id in the authors collection
related: z.array(reference('blog')).default([]),
}),
});
export const collections = { authors, blog };

reference('authors') เก็บ id ของ author และรับประกันว่าชี้ไปที่ entry จริง ถ้าจะเปลี่ยน reference นั้นให้เป็นข้อมูลจริง ก็ resolve ด้วย getEntry:

---
import { getEntry, getCollection } from 'astro:content';
const post = (await getCollection('blog'))[0];
const author = await getEntry(post.data.author); // pass the reference straight in
---
<p>By {author.data.name}</p>

วิธีนี้ทำให้ content เป็น normalized ข้อมูล author อยู่ที่เดียว ส่วนแต่ละโพสต์เก็บแค่ลิงก์ที่ validate แล้วและ resolve ได้

schema เป็นฟังก์ชันธรรมดาถ้าใช้แค่ z แต่พอ field เป็นรูปในเครื่อง Astro จะส่ง helper เพิ่มเข้ามาใน schema callback: ({ image }) => z.object({ ... }) เรียก image() สำหรับ field นั้น แล้ว path ดิบใน frontmatter (cover: "./cover.png") จะกลายเป็น image object ที่ validate แล้วและ optimize แล้วตอน build:

src/content.config.ts
import { defineCollection, reference } from 'astro:content';
import { z } from 'astro/zod';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),
schema: ({ image }) => z.object({
title: z.string(),
author: reference('authors'),
cover: image(), // validates the path AND generates an optimized asset
coverAlt: z.string(),
}),
});
export const collections = { blog };
src/pages/index.astro
---
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
const post = posts[0];
---
{post && (
<img src={post.data.cover.src} width={post.data.cover.width} height={post.data.cover.height} alt={post.data.coverAlt} />
)}

post.data.cover ไม่ใช่ string — มันคือ { src, width, height, format, ... } shape เดียวกับที่ astro:assets ให้ <Image> พิสูจน์แล้วในโพรบ: build ด้วย cover: "./cover.png" ใน frontmatter สร้างไฟล์จริงที่ hash แล้วและ optimize แล้ว พร้อมใส่ขนาดจริงลง HTML โดยไม่ต้องใช้ component <Image> เอง:

<!-- real build output -->
<img src="/_astro/cover.asuJasgv.png" width="1" height="1" alt="A sunset">

image() resolve path เทียบกับไฟล์ของ entry เอง จึงเป็นเหตุที่มันทำงานได้แค่กับ loader (อย่าง glob) ที่ตั้ง filePath ให้แต่ละ entry — loader ที่ตั้งแค่ id/data (แบบ notesLoader จากบทก่อน) ไม่มีอะไรให้ image() resolve เทียบด้วย

การ validate ไม่ใช่แค่คำเตือน — entry ที่ผิดหยุด build ทันที และข้อความบอกชัดว่า field ไหนของ entry ไหน frontmatter นี้มี pubDate ที่ไม่ใช่วันที่จริง:

---
title: "Bad Post"
pubDate: "soon"
author: ada
cover: "./cover.png"
coverAlt: "broken"
---

…เทียบกับ field pubDate: z.coerce.date() จาก schema ของบทนี้ astro sync (และ astro dev/astro build ที่ sync ก่อน) จะ fail ทันที — นี่คือข้อความ error จริงบน Astro 7.3.5:

[InvalidContentEntryDataError] blog → bad-post data does not match collection schema.
pubDate**: **pubDate: Expected type "date", received "object"
Location:
src/data/blog/bad-post.md:0:0

"soon" ผ่าน new Date("soon") แล้วได้ Invalid Date object ไม่ใช่ string — นี่คือเหตุผลที่ error บอกว่า “received object” ไม่ใช่ “received string” ข้อความบอกทั้ง collection (blog) entry (bad-post) และ field (pubDate) การแก้ content ที่ผิดจึงเป็นแค่แก้บรรทัดเดียว ไม่ใช่เซสชัน debug

มอง schema เป็นด่านหน้าประตูของ collection ไม่ใช่ type annotation ที่แปะเพิ่มทีหลัง ไม่มีอะไรเข้า content store ได้โดยไม่ผ่านด่านนี้ก่อน — นี่คือเหตุผลที่ entry ผิดจะ fail ตอน sync ก่อนที่หน้าไหนจะมีโอกาส render มันเลย และเหตุผลที่ type ที่ generate มาไม่ใช่การเดา — มันสะท้อนตรงจากด่านเดียวกันที่ทุกอย่างผ่านมาแล้ว image() ขยายด่านเดียวกันนี้ไปถึง asset ที่เป็นไบนารีด้วย — path ใน frontmatter ก็ “ยังไม่ validate” พอ ๆ กับ field string จนกว่า schema จะ resolve และ optimize มันให้

สิ่งที่ได้ ประโยชน์ ต้นทุน
schema ต่อ collection content ที่ผิด build ล้มพร้อมข้อความระดับ field ที่แม่นยำ ต้องอธิบายทุก field ไว้ก่อน field ที่ไม่ได้ประกาศจะหายจาก entry.data เงียบ ๆ ไม่ error
z.coerce.date() / .default() / .optional() โค้ดปลายทางไม่ต้องจัดการ raw string หรือ undefined เอง coercion อาจบังค่าที่ผิดจริง ๆ ให้ดูเหมือน valid-แต่ผิด ("soon" กลายเป็น Invalid Date ไม่ใช่ string error ที่จับได้ทันที จนกว่าจะอ่านข้อความละเอียด)
reference() ลิงก์ที่ validate แล้วและมี type ระหว่าง collection resolve ด้วย getEntry แค่ครั้งเดียว reference เป็นแค่ id จนกว่าจะ resolve — อ่าน .data ตรงจากมันคือ type error ไม่ใช่ undefined เงียบ ๆ
image() ใน schema callback รูปในเครื่อง optimize และมี type ให้โดยไม่ต้องเขียน <Image> เพิ่ม ใช้ได้แค่กับ loader ที่ตั้ง filePath ต่อ entry (glob ทำ ส่วน loader ในหน่วยความจำที่เขียนเองมักไม่ทำ)
  • “schema แค่เติม TypeScript type ให้” — มัน validate ตอน build ก่อน type เป็นแค่ผลพลอยได้จาก validation ที่ผ่าน field หนึ่งอาจมี type ถูกเป๊ะแต่ validation ยังล้มได้ (z.string().max(80) มี type เป็น string แต่ title 81 ตัวอักษรก็ยัง build ล้ม)
  • “z.coerce.date() parse ผ่านหรือไม่ก็ throw ทันที” — string ที่ parse ไม่ได้ไม่ throw ข้างใน z.coerce.date() — มันได้ Invalid Date แล้ว Zod ค่อย report ว่า Expected type "date", received "object" error เกิดจริงและ block build จริง แต่ข้อความทำเอาคนที่คาดหวัง "received string" แปลกใจ
  • “image() ใช้ได้กับ loader ไหนก็ได้ เพราะมันเป็นแค่ส่วนหนึ่งของ schema” — มัน resolve path เทียบกับไฟล์ของ entry จึงต้องให้ loader บันทึกไว้ว่าไฟล์นั้นอยู่ไหน (filePath) loader ที่ตั้งแค่ id/data — อย่าง API หรือ database loader ที่เขียนเอง — ไม่มีไฟล์ให้ image() resolve เทียบด้วย
  • “field reference('authors') มีข้อมูล author อยู่ในตัวแล้ว” — มันคือ id ที่ validate แล้ว ไม่ใช่ entry ที่ resolve แล้ว post.data.author.name คือ type error เรียก getEntry(post.data.author) ก่อนถึงจะได้

เมื่อ snippet ที่ generate มาประกาศ schema หรือ reference ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:

  • schema เป็น object ธรรมดา (schema: z.object({...})) ทั้งที่ใช้ image() หรือเปล่า? image() มีให้ใช้แค่ใน callback form เท่านั้น (schema: ({ image }) => z.object({...})) — ใช้มันใน plain object schema คือ reference error เงียบ ๆ ไม่ใช่ type mismatch
  • โค้ดอ่าน post.data.author.name ตรงจาก field reference() เลยหรือเปล่า แทนที่จะ resolve ก่อนด้วย await getEntry(post.data.author)?
  • .default(...) อยู่บน field ที่ mark .optional() ด้วยหรือเปล่า? สองอย่างนี้ทับซ้อนกันแบบชวนสับสน — แค่ .default() ก็ทำให้ field นั้น optional ใน input อยู่แล้ว
  • ถ้า field วันที่ใช้ z.coerce.date() โค้ดรอบ ๆ มันสมมติว่า parse ผิดจะ throw ทันทีไหม แทนที่จะรู้ว่ามันได้ Invalid Date ที่โผล่เป็น validation error ที่ schema boundary เท่านั้น?
  • schema ลืม .default([]) บน field array ที่ใน content จริงเป็น optional ไหม — ทำให้ทุกที่ที่ใช้ entry.data.tags ต้องเติม ?? [] เอง?

🐛 หา bug ในโค้ดนี้

---
title: "Bad Post"
pubDate: "soon"
author: ada
cover: "./cover.png"
coverAlt: "broken"
---
This post has a bad pubDate.
เฉลย

frontmatter pubDate: "soon" ไม่ใช่วันที่ที่ parse ได้ แต่ schema ของ collection นี้ประกาศ pubDate: z.coerce.date() z.coerce.date() ไม่ได้ reject string ที่ parse ไม่ได้ตรง ๆ — มันเอาไปรันผ่าน new Date("soon") ซึ่งได้ Invalid Date object จริงของ JavaScript แล้ว validator ของ Zod ก็ report object นั้นว่าเป็น type ผิด พิสูจน์แล้วในโพรบบน Astro 7.3.5: astro sync fail ทันทีด้วย [InvalidContentEntryDataError] blog → bad-post data does not match collection schema พร้อมรายละเอียดระดับ field pubDate: Expected type "date", received "object" — น่าอ่านให้ละเอียด เพราะ “received object” (ไม่ใช่ “string”) คือลายเซ็นของ coercion ที่ได้ Invalid Date ไม่ใช่แค่ type ผิดตรง ๆ ทางแก้คือเขียนวันที่จริงที่ parse ได้ใน frontmatter (pubDate: 2026-01-03)

นอกจากจับ content ที่ผิดแล้ว schema ให้อะไรอีก?
.default(false) บน field ของ schema ทำอะไร?
reference() ใช้ทำอะไร?
การ validate content เกิดขึ้นที่ไหน?
schema นี้เขียนเป็น object ธรรมดา ไม่ใช่ callback เกิดอะไรขึ้นตอน Astro โหลดมัน?
schema: z.object({
  title: z.string(),
  cover: image(),
}),
ถ้า schema field เป็น pubDate: z.coerce.date() astro sync จะ report error จริงอะไรบน Astro 7.3.5?
---
title: "Bad Post"
pubDate: "soon"
---