Skip to content

TypeScript & Content Types

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.

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.

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 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"]
Your schema becomes your types

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.

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:

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

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>

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:

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 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.

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"
}
}

A generated .astro/env.d.ts/astro:env snippet is worth checking against the real generated types:

  • Does it declare App.Locals differently in more than one file of the same project (e.g. one file says user: { id: string }, another says user: SomeOtherShape)? Ambient declarations merge project-wide — proven above, this course deliberately uses one identical shape everywhere App.Locals is shown, specifically to avoid that clash.
  • Does it hand-write a type for getCollection('blog')’s return value instead of using CollectionEntry<'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/client inside a file that also imports something server-only, or the reverse? astro:env/client and astro:env/server are two separate virtual modules by design (proven above) — a secret declared context: '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/base is the strict/recommended option? strict or strictest are the ones that actually catch bugs; base is the loosest of the three.
  • Does a getStaticPaths page retype its own props shape by hand instead of InferGetStaticPropsType<typeof getStaticPaths>? The hand-written version can drift silently from what getStaticPaths actually returns; the inferred one can’t.

🐛 Spot the bug

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

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.

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
  • “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 by astro sync.
  • “astro:env/client and astro:env/server are the same module, just organized by convention.” They’re separate virtual modules (proven above with the real generated .astro/env.d.ts) — a context: 'server' var genuinely does not exist on the client one.
  • “TypeScript preset choice (base/strict/strictest) barely matters.” base is deliberately the loosest of the three; strict/strictest turn on the checks that catch real bugs — proven by their names being an actual escalation, not marketing labels.
How do you type a component’s props in Astro?
Where do the types for getCollection('blog') come from?
How do you type data attached to Astro.locals (e.g. by middleware)?
What does astro check do?
A second file adds: What happens?
// 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;
  }
}
After astro sync, what does the generated .astro/env.d.ts actually contain?
// astro.config.mjs
export default defineConfig({
  env: {
    schema: {
      API_URL: envField.string({ context: 'client', access: 'public' }),
      API_SECRET: envField.string({ context: 'server', access: 'secret' }),
    },
  },
});