TypeScript & Content Types
TypeScript มีมาให้ในตัว
หัวข้อที่มีชื่อว่า “TypeScript มีมาให้ในตัว”Astro มาพร้อม TypeScript support ในตัว — เขียนไฟล์ .ts, type component script และได้ type-checking ใน editor โดยไม่ต้องตั้งอะไร โปรเจกต์ใหม่ extend preset ตัวหนึ่งของ Astro ใน tsconfig.json:
{ "extends": "astro/tsconfigs/strict"}preset มี base, strict และ strictest — เลือก strict (หรือ strictest) สำหรับโปรเจกต์จริง เพราะเปิด check ที่จับ bug ได้จริง
การ type props ของ component
หัวข้อที่มีชื่อว่า “การ type props ของ component”คุณ type props ของ component ด้วยการประกาศ interface Props ใน frontmatter แล้ว Astro จะใช้ interface นี้ type-check ทุกที่ที่ component ถูกใช้:
---interface Props { title: string; count?: number; // optional prop}const { title, count = 0 } = Astro.props;---<h1>{title} ({count})</h1>ส่ง type ผิดหรือลืม prop ที่จำเป็น editor (และ astro check) จะเตือนที่จุดเรียกใช้
type ที่ generate จาก content schema
หัวข้อที่มีชื่อว่า “type ที่ generate จาก content schema”ตรงนี้แหละที่ typing ของ Astro โดดเด่น เมื่อคุณ define content collection ด้วย Zod schema (จากโมดูล content) Astro generate TypeScript type จาก schema นั้น getCollection และ getEntry จะคืน entry ที่มี type ครบ — editor รู้ทุก field ใน frontmatter และ type ของตัวเอง:
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');// posts[0].data.title -> string (from the schema)// posts[0].data.pubDate -> Date (z.coerce.date())// posts[0].data.nope -> type error: property does not existflowchart LR schema["Zod schema in src/content.config.ts"] --> gen["Astro generates types"] gen --> query["getCollection / getEntry return typed data"] query --> safe["type errors on wrong field access"]
type เหล่านี้อยู่ในโฟลเดอร์ .astro/ ที่ generate ให้ ซึ่ง Astro ดูแลให้เอง (refresh โดย dev server และ astro sync) คุณไม่ต้องแก้เอง แค่ได้ autocomplete และความปลอดภัยฟรีจาก schema ที่คุณเขียนไว้แล้ว
astro sync และสิ่งที่ไปโผล่ใน .astro/ จริง ๆ
หัวข้อที่มีชื่อว่า “astro sync และสิ่งที่ไปโผล่ใน .astro/ จริง ๆ”astro sync (ที่ astro dev/astro check/astro build เรียกโดยอัตโนมัติด้วย) อ่าน config ของ collection แล้วเขียน TypeScript จริงที่ check ได้ — ไม่ใช่แค่ declaration ที่บอกเจตนา พิสูจน์ตรง ๆ จาก astro sync จริงบน collection ตายตัวของ probe คอร์สนี้ (blog, authors) .astro/content.d.ts ที่ generate มีกลไก CollectionEntry/InferEntrySchema จริง ๆ อยู่ในนั้น:
// .astro/content.d.ts (generated — never hand-edit)export type CollectionEntry<C extends CollectionKey> = Flatten<DataEntryMap[C]>;// ...type InferEntrySchema<C extends keyof DataEntryMap> = import('astro/zod').infer< ContentConfig['collections'][C]['schema']>;สองชื่อที่ควรจำหน้าได้: CollectionEntry<'blog'> คือ type ของหนึ่ง entry เต็ม ๆ (id, ชื่อ collection และ data); InferEntrySchema<'blog'> คือแค่ shape ของ data เดี่ยว ๆ infer มาจาก Zod schema ตรง ๆ (มีประโยชน์เวลา helper function ต้องการแค่ shape ของ frontmatter ไม่ใช่ทั้ง entry wrapper):
import type { CollectionEntry } from 'astro:content';
function renderTitle(entry: CollectionEntry<'blog'>) { return entry.data.title; // typed as string, straight from the schema}ก่อนที่ astro sync จะรันเลยสักครั้ง astro/types/content.d.ts ship fallback แบบ static ให้ (export type CollectionEntry<C> = any;) เพื่อให้โปรเจกต์ compile ได้อยู่ — แต่ fallback นั้นตั้งใจให้ไม่มี type เลย นั่นแหละคือเหตุผลที่ astro check/astro build รัน sync ก่อนเสมอ แทนที่จะเชื่อว่า .astro/ มีอะไรอยู่แล้วก่อนหน้านั้น
Type Astro.locals ด้วย App.Locals
หัวข้อที่มีชื่อว่า “Type Astro.locals ด้วย App.Locals”เมื่อใช้ middleware แนบข้อมูลระดับ request (เช่น user ปัจจุบัน) ไว้ที่ Astro.locals คุณกำหนด type ให้ locals ด้วยการประกาศ interface App.Locals มักอยู่ใน src/env.d.ts ambient declaration แบบนี้ไม่ได้ scope อยู่แค่ไฟล์เดียวในโปรเจกต์จริง — ทุกไฟล์ .ts/.astro เห็น App.Locals ตัวเดียวกันที่ merge กันแล้ว — คอร์สนี้เลยใช้ shape เดียวทุกที่ที่โชว์ ตรงกับบท middleware & sessions เป๊ะ:
type User = { id: string; name: string };
declare namespace App { interface Locals { user: User | null; requestId: string; }}ตอนนี้ Astro.locals.user และ Astro.locals.requestId มี type ทุกที่ — ใน middleware, page และ endpoint
Type Astro.params/Astro.props จาก getStaticPaths
หัวข้อที่มีชื่อว่า “Type Astro.params/Astro.props จาก getStaticPaths”getStaticPaths ของ dynamic route คืน { params, props } ต่อหน้าที่ generate และ InferGetStaticPropsType (verify แล้วใน astro/dist/types/public/common.d.ts) ดึง type จริงของ props มาจากตัว function เอง แทนที่จะ retype เองด้วยมือ:
---import type { InferGetStaticPropsType } from 'astro';
export async function getStaticPaths() { return [ { params: { slug: 'hello-world' }, props: { title: 'Hello World', views: 42 } }, ];}
type Props = InferGetStaticPropsType<typeof getStaticPaths>;const { title, views } = Astro.props; // title: string, views: number — inferred, not retyped by handconst { slug } = Astro.params; // slug is typed from the route's own [slug] segment---<h1>{title} ({views} views) — /{slug}</h1>astro:env typed vars
หัวข้อที่มีชื่อว่า “astro:env typed vars”การประกาศ schema ใต้ key config env (stable แล้ว ไม่ได้อยู่ใต้ experimental — verify แล้วจาก schema ที่ติดตั้งจริง) ทำให้ Astro generate real export ที่มีชื่อจริงให้ astro:env/client และ astro:env/server — ไม่ใช่ Record<string, string> แบบ generic พิสูจน์ด้วย astro sync จริงกับ config เป๊ะ ๆ นี้:
import { defineConfig, envField } from 'astro/config';
export default defineConfig({ env: { schema: { API_URL: envField.string({ context: 'client', access: 'public' }), API_SECRET: envField.string({ context: 'server', access: 'secret' }), }, },});// .astro/env.d.ts (generated — never hand-edit)declare module 'astro:env/client' { export const API_URL: string;}declare module 'astro:env/server' { export const API_SECRET: string;}API_URL import ได้จาก astro:env/client (และเพราะเป็น context: 'client' ปลอดภัยในโค้ด browser); API_SECRET มีอยู่แค่ใน astro:env/server — import มันจาก module ที่รันฝั่ง client เป็น module-resolution error จริง โดยการออกแบบ ไม่ใช่การรั่วตอน runtime แบบ .env var ที่ไม่มี prefix
type-check ด้วย astro check
หัวข้อที่มีชื่อว่า “type-check ด้วย astro check”editor type-check ให้ระหว่างเขียน แต่ไฟล์ .astro ไม่ถูก check โดย tsc คำสั่งที่ทำคือ astro check — validate type ข้ามไฟล์ .astro, .ts และ .tsx ต่อคำสั่งนี้เข้ากับ build เพื่อให้ type error หยุด deploy ที่พัง:
{ "scripts": { "build": "astro check && astro build" }}อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”snippet .astro/env.d.ts/astro:env ที่ AI generate มา ควรเช็คกับ type ที่ generate จริง:
- มันประกาศ
App.Localsไม่เหมือนกันในมากกว่าหนึ่งไฟล์ของโปรเจกต์เดียวกันหรือเปล่า (เช่นไฟล์หนึ่งบอกuser: { id: string }อีกไฟล์บอกuser: SomeOtherShape)? ambient declaration merge กันทั้งโปรเจกต์ — พิสูจน์แล้วข้างบน คอร์สนี้ตั้งใจใช้ shape เดียวกันเป๊ะทุกที่ที่โชว์App.Localsเพื่อเลี่ยงการชนแบบนั้นโดยเฉพาะ - มันเขียน type ของ return value
getCollection('blog')เองด้วยมือ แทนที่จะใช้CollectionEntry<'blog'>หรือเปล่า? type ที่ generate ไว้ (พิสูจน์แล้วข้างบน จาก.astro/content.d.tsจริง) มีอยู่แล้ว การเขียนซ้ำเองด้วยมือจะ drift ทันทีที่ schema เปลี่ยน - มัน import จาก
astro:env/clientในไฟล์ที่ import อะไรที่ server-only ด้วย หรือกลับกัน?astro:env/clientกับastro:env/serverเป็นสอง virtual module แยกกันโดยการออกแบบ (พิสูจน์แล้วข้างบน) — var ที่ประกาศcontext: 'server'ไม่มีอยู่บน client module เลย ดังนั้น import จากตรงนั้นเป็น module-resolution error จริง ไม่ใช่การรั่วตอน runtime ที่ต้องจับทีหลัง - มันอ้างว่า
astro/tsconfigs/baseคือตัวเลือกที่เข้มงวด/แนะนำหรือเปล่า?strictหรือstrictestต่างหากที่จับ bug ได้จริงbaseคือตัวที่หลวมที่สุดในสามตัว - หน้า
getStaticPathsretype shape ของpropsตัวเองด้วยมือ แทนที่จะใช้InferGetStaticPropsType<typeof getStaticPaths>หรือเปล่า? เวอร์ชันที่เขียนเองด้วยมือ drift ออกจากสิ่งที่getStaticPathsคืนจริงได้เงียบ ๆ ส่วนเวอร์ชันที่ infer มา drift ไม่ได้
🐛 หา bug ในโค้ดนี้
declare namespace App {interface Locals { user: { id: string; role: string } | null;}}เฉลย
โปรเจกต์นี้มี src/env.d.ts ที่ประกาศ App.Locals ด้วย user: User | null; requestId: string อยู่แล้ว (โชว์ไว้ก่อนหน้าในบทนี้ ตรงกับบท middleware & sessions) การเพิ่ม declare namespace App { interface Locals { user: ...} } ตัวที่สอง ที่มี user shape ต่างออกไป (ตรงนี้คือ { id: string; role: string } แทน { id: string; name: string }) ไม่ได้ override ตัวแรก — TypeScript merge declaration ชื่อเดียวกันทั้งโปรเจกต์ และ type ที่ประกาศไม่ตรงกันสองแบบสำหรับ property user เดียวกันบน interface ที่ merge แล้วเดียวกัน เป็นการชนกันจริงระดับ type (error แบบ “subsequent property declarations must have the same type”) ambient global declaration ไม่ได้ scope ตามไฟล์แบบที่ import เป็น — มี App.Locals แค่หนึ่งเดียวต่อโปรเจกต์ ไม่ว่าจะมีกี่ไฟล์มาช่วยประกอบมันก็ตาม
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”เรื่องราวการ type ของ Astro มีธีมเดียวที่วนซ้ำ: อย่าเขียน type เองด้วยมือถ้ามันสามารถ generate จากอะไรที่คุณเขียนไว้แล้วครั้งเดียวได้ (Zod schema, return ของ getStaticPaths, env.schema) CollectionEntry, InferEntrySchema, และ InferGetStaticPropsType ล้วนเป็นท่าเดียวกัน — ชี้ type helper ไปที่แหล่งความจริงตัวจริง แทนที่จะ copy shape ของมันด้วยมือ เพื่อให้การเปลี่ยน schema กลายเป็นการเปลี่ยน type อัตโนมัติ โดยไม่มีไฟล์ที่สองให้ต้องจำไปแก้
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| Type ที่เขียนเอง | Type ที่ Astro generate/infer ให้ | |
|---|---|---|
| แหล่งความจริง | ไม่ว่าคุณ copy มาจากไหน | ตัว schema/function เอง |
| drift เมื่อ schema เปลี่ยนไหม | ใช่ — เงียบ ๆ จนกว่าอะไรจะพัง | ไม่ — type อัปเดตตามไปด้วย |
| การ setup | ไม่มี | astro sync (อัตโนมัติใน dev/build/check) |
| เหมาะกับ | ข้อมูลที่ไม่ได้มาจาก schema จริง ๆ | content collection, props ของ getStaticPaths, astro:env |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “แต่ละบท/ไฟล์ประกาศ
App.Localsเวอร์ชันของตัวเองได้” พิสูจน์แล้วข้างบน: ambient declaration merge กันทั้งโปรเจกต์ และสองไฟล์ที่ประกาศ type ไม่ตรงกันสำหรับ property เดียวกันคือการชนจริง ไม่ใช่การ override - “
CollectionEntry<'blog'>เป็นแค่ documentation ของ shape ไม่ใช่ type ที่ generate จริง” พิสูจน์แล้วข้างบนด้วยการอ่าน.astro/content.d.tsที่ generate จริง: มันคือ TypeScript ที่ check ได้จริง generate สด ๆ จาก Zod schema โดยastro sync - “
astro:env/clientกับastro:env/serverเป็น module เดียวกัน แค่จัดตาม convention” เป็นคนละ virtual module (พิสูจน์แล้วข้างบนด้วย.astro/env.d.tsที่ generate จริง) — var ที่context: 'server'ไม่มีอยู่บน client module จริง ๆ - “การเลือก TypeScript preset (
base/strict/strictest) แทบไม่มีผล”baseตั้งใจให้หลวมที่สุดในสามตัวstrict/strictestเปิด check ที่จับ bug ได้จริง — พิสูจน์จากชื่อที่เป็นการไล่ระดับจริง ไม่ใช่แค่ label การตลาด