TypeScript & Content Types
TypeScript is built in
Section titled “TypeScript is built in”Astro ships TypeScript support out of the box — you can write .ts files, type your component scripts, and get editor type-checking with zero setup. A new project extends one of Astro’s presets in tsconfig.json:
{ "extends": "astro/tsconfigs/strict"}The presets are base, strict, and strictest — pick strict (or strictest) for real projects; it turns on the checks that catch actual bugs.
Typing component props
Section titled “Typing component props”You type a component’s props by declaring a Props interface in the frontmatter. Astro uses it to type-check every place the component is used:
---interface Props { title: string; count?: number; // optional prop}const { title, count = 0 } = Astro.props;---<h1>{title} ({count})</h1>Pass a wrong type or forget a required prop, and the editor (and astro check) flags it at the call site.
Types generated from content schemas
Section titled “Types generated from content schemas”This is where Astro’s typing shines. When you define a content collection with a Zod schema (from the content module), Astro generates TypeScript types from that schema. getCollection and getEntry then return fully-typed entries — your editor knows every frontmatter field and its 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"]
These types live in a generated .astro/ folder that Astro maintains for you (refreshed by the dev server and by astro sync). You never edit them; you just get autocomplete and safety for free from the schema you already wrote.
astro sync and what actually lands in .astro/
Section titled “astro sync and what actually lands in .astro/”astro sync (also run implicitly by astro dev/astro check/astro build) reads your collection configs and writes real, checkable TypeScript — not just declarations of intent. Proven directly against a real astro sync, the generated .astro/content.d.ts for this course’s fixed probe collections (blog, authors) contains the real CollectionEntry/InferEntrySchema machinery:
// .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']>;Two names worth knowing by sight: CollectionEntry<'blog'> is the type of one full entry (id, collection name, and data); InferEntrySchema<'blog'> is just the data shape on its own, inferred straight from the Zod schema (useful when a helper function only needs the frontmatter shape, not the whole entry wrapper):
import type { CollectionEntry } from 'astro:content';
function renderTitle(entry: CollectionEntry<'blog'>) { return entry.data.title; // typed as string, straight from the schema}Before astro sync has ever run, astro/types/content.d.ts ships a static fallback (export type CollectionEntry<C> = any;) so the project still compiles — but that fallback is untyped by design, which is exactly why astro check/astro build run sync first rather than trusting whatever .astro/ happened to contain already.
Typing Astro.locals with App.Locals
Section titled “Typing Astro.locals with App.Locals”When you use middleware to attach request-scoped data (like the current user) to Astro.locals, you type it by declaring the App.Locals interface, usually in src/env.d.ts. Ambient declarations like this aren’t scoped to one file in a real project — every .ts/.astro file sees the same merged App.Locals — so this course uses one single shape everywhere it’s shown, matching the middleware & sessions lesson exactly:
type User = { id: string; name: string };
declare namespace App { interface Locals { user: User | null; requestId: string; }}Now Astro.locals.user and Astro.locals.requestId are typed everywhere — in middleware, pages, and endpoints.
Typed Astro.params/Astro.props from getStaticPaths
Section titled “Typed Astro.params/Astro.props from getStaticPaths”A dynamic route’s getStaticPaths returns { params, props } per generated page, and InferGetStaticPropsType (verified in astro/dist/types/public/common.d.ts) recovers the exact props type from the function itself, instead of retyping it by hand:
---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
Section titled “astro:env typed vars”Declaring a schema under the env config key (stable, not under experimental — verified against the installed schema) makes Astro generate real, named exports for astro:env/client and astro:env/server — not a generic Record<string, string>. Proven with a real astro sync against this exact 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 is importable from astro:env/client (and, being context: 'client', safe in browser code); API_SECRET only exists on astro:env/server — importing it from a client-run module is a real module-resolution error, by construction, not a runtime leak like an unprefixed .env var can be.
Type-checking with astro check
Section titled “Type-checking with astro check”Editors type-check as you go, but .astro files aren’t checked by tsc. The command that does it is astro check — it validates types across your .astro, .ts, and .tsx files. Wire it into your build so type errors block a broken deploy:
{ "scripts": { "build": "astro check && astro build" }}Reading AI-generated code
Section titled “Reading AI-generated code”A generated .astro/env.d.ts/astro:env snippet is worth checking against the real generated types:
- Does it declare
App.Localsdifferently in more than one file of the same project (e.g. one file saysuser: { id: string }, another saysuser: SomeOtherShape)? Ambient declarations merge project-wide — proven above, this course deliberately uses one identical shape everywhereApp.Localsis shown, specifically to avoid that clash. - Does it hand-write a type for
getCollection('blog')’s return value instead of usingCollectionEntry<'blog'>? The generated type (proven above, from real.astro/content.d.ts) is already there, and a hand-written duplicate drifts the moment the schema changes. - Does it import from
astro:env/clientinside a file that also imports something server-only, or the reverse?astro:env/clientandastro:env/serverare two separate virtual modules by design (proven above) — a secret declaredcontext: 'server'simply doesn’t exist on the client module, so importing it there is a real module-resolution error, not a runtime leak to catch later. - Does it claim
astro/tsconfigs/baseis the strict/recommended option?strictorstrictestare the ones that actually catch bugs;baseis the loosest of the three. - Does a
getStaticPathspage retype its ownpropsshape by hand instead ofInferGetStaticPropsType<typeof getStaticPaths>? The hand-written version can drift silently from whatgetStaticPathsactually returns; the inferred one can’t.
🐛 Spot the bug
declare namespace App {interface Locals { user: { id: string; role: string } | null;}}Show the answer
This project already has src/env.d.ts declaring App.Locals with user: User | null; requestId: string (shown earlier in this lesson, matching the middleware & sessions lesson). Adding a second declare namespace App { interface Locals { user: ...} } with a differently-shaped user (here, { id: string; role: string } instead of { id: string; name: string }) does not override the first — TypeScript merges same-named declarations project-wide, and two incompatible declared types for the identical property user on the identical merged interface is a real conflict at the type level (a “subsequent property declarations must have the same type” error). Ambient global declarations aren’t file-scoped the way an import is; there’s exactly one App.Locals per project, however many files contribute to it.
Mental Model
Section titled “Mental Model”Astro’s typing story has one running theme: don’t hand-write a type that could instead be generated from something you already wrote once (a Zod schema, a getStaticPaths return, an env.schema). CollectionEntry, InferEntrySchema, and InferGetStaticPropsType are all the same move — point a type helper at the real source of truth instead of copying its shape by hand, so a change to the schema is a change to the types automatically, with no second file to remember to update.
Trade-offs
Section titled “Trade-offs”| Hand-written type | Astro-generated / inferred type | |
|---|---|---|
| Source of truth | Wherever you copied it from | The schema/function itself |
| Drifts when the schema changes? | Yes — silently, until something breaks | No — the type updates with it |
| Setup | None | astro sync (automatic in dev/build/check) |
| Best for | Data that genuinely isn’t schema-derived | Content collections, getStaticPaths props, astro:env |
Common Misconceptions
Section titled “Common Misconceptions”- “Each lesson/file can declare its own version of
App.Locals.” Proven above: ambient declarations merge project-wide, and two files declaring incompatible types for the same property is a real conflict, not an override. - “
CollectionEntry<'blog'>is just documentation of the shape, not a real generated type.” Proven above by reading the actual generated.astro/content.d.ts: it’s real, checkable TypeScript, generated fresh from the Zod schema byastro sync. - “
astro:env/clientandastro:env/serverare the same module, just organized by convention.” They’re separate virtual modules (proven above with the real generated.astro/env.d.ts) — acontext: 'server'var genuinely does not exist on the client one. - “TypeScript preset choice (
base/strict/strictest) barely matters.”baseis deliberately the loosest of the three;strict/strictestturn on the checks that catch real bugs — proven by their names being an actual escalation, not marketing labels.