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

TypeScript & Content Types

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 ด้วยการประกาศ 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) จะเตือนที่จุดเรียกใช้

ตรงนี้แหละที่ 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 exist
flowchart 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"]
schema กลายเป็น type ของคุณ

type เหล่านี้อยู่ในโฟลเดอร์ .astro/ ที่ generate ให้ ซึ่ง Astro ดูแลให้เอง (refresh โดย dev server และ astro sync) คุณไม่ต้องแก้เอง แค่ได้ autocomplete และความปลอดภัยฟรีจาก schema ที่คุณเขียนไว้แล้ว

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/ มีอะไรอยู่แล้วก่อนหน้านั้น

เมื่อใช้ 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 เป๊ะ:

src/env.d.ts
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

getStaticPaths ของ dynamic route คืน { params, props } ต่อหน้าที่ generate และ InferGetStaticPropsType (verify แล้วใน astro/dist/types/public/common.d.ts) ดึง type จริงของ props มาจากตัว function เอง แทนที่จะ retype เองด้วยมือ:

src/pages/blog/[slug].astro
---
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 hand
const { slug } = Astro.params; // slug is typed from the route's own [slug] segment
---
<h1>{title} ({views} views) — /{slug}</h1>

การประกาศ 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 เป๊ะ ๆ นี้:

astro.config.mjs
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

editor type-check ให้ระหว่างเขียน แต่ไฟล์ .astro ไม่ถูก check โดย tsc คำสั่งที่ทำคือ astro check — validate type ข้ามไฟล์ .astro, .ts และ .tsx ต่อคำสั่งนี้เข้ากับ build เพื่อให้ type error หยุด deploy ที่พัง:

{
"scripts": {
"build": "astro check && astro build"
}
}

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 คือตัวที่หลวมที่สุดในสามตัว
  • หน้า getStaticPaths retype shape ของ props ตัวเองด้วยมือ แทนที่จะใช้ InferGetStaticPropsType<typeof getStaticPaths> หรือเปล่า? เวอร์ชันที่เขียนเองด้วยมือ drift ออกจากสิ่งที่ getStaticPaths คืนจริงได้เงียบ ๆ ส่วนเวอร์ชันที่ infer มา drift ไม่ได้

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

src/env.d.ts
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 แค่หนึ่งเดียวต่อโปรเจกต์ ไม่ว่าจะมีกี่ไฟล์มาช่วยประกอบมันก็ตาม

เรื่องราวการ 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 การตลาด
type props ของ component ใน Astro ยังไง?
type ของ getCollection('blog') มาจากไหน?
type ข้อมูลที่แนบไว้ที่ Astro.locals (เช่นโดย middleware) ยังไง?
astro check ทำอะไร?
ไฟล์ที่สองเพิ่ม: จะเกิดอะไรขึ้น?
// src/env.d.ts (already exists, elsewhere in this project)
type User = { id: string; name: string };
declare namespace App {
  interface Locals {
    user: User | null;
    requestId: string;
  }
}
declare namespace App {
  interface Locals {
    user: { id: string; role: string } | null;
  }
}
หลัง astro sync .astro/env.d.ts ที่ generate มีอะไรอยู่จริง ๆ?
// astro.config.mjs
export default defineConfig({
  env: {
    schema: {
      API_URL: envField.string({ context: 'client', access: 'public' }),
      API_SECRET: envField.string({ context: 'server', access: 'secret' }),
    },
  },
});