Type something to search...
How to type component props with TypeScript in Astro?

How to type component props with TypeScript in Astro?

To type component props in Astro, declare an interface or type named Props in the component script, then destructure Astro.props as usual: interface Props { title: string; count?: number }. Astro picks up Props by name, with no export or generic needed, and uses it to type Astro.props inside the component and to check every place the component is used. Errors appear in your editor through the Astro VS Code extension and on the command line with astro check. For wrappers around HTML elements, extend HTMLAttributes<"a"> from astro/types, and to reuse another component's props, use ComponentProps<typeof Other>.

Astro treats every component script as TypeScript, so you get type safety without any setup beyond the tsconfig.json that starter projects include. In this article you'll learn the basic Props pattern, how to type optional props, defaults, unions and children, how to use the built-in HTMLAttributes, ComponentProps and Polymorphic helpers, how to type props for dynamic routes and content collections, and how to make the build fail on type errors. All of the examples were type-checked with astro check on Astro 7.4.2.

The Basic Pattern

---
// src/components/Alert.astro
interface Props {
  type: "info" | "success" | "warning" | "error";
  title?: string;
  dismissible?: boolean;
}

const { type, title, dismissible = false } = Astro.props;
---
<div class={`alert alert-${type}`} role="alert">
  {title && <strong>{title}</strong>}
  <slot />
  {dismissible && <button type="button" aria-label="Dismiss">×</button>}
</div>

Inside the component, type is the union of four strings, title is string | undefined and dismissible is boolean thanks to the default. Outside, any use of <Alert> is checked against the interface:

<Alert type="info">Heads up.</Alert>
<Alert type="success" title="Saved" dismissible>Your changes were saved.</Alert>

Some details about how Astro finds the type:

  • The name matters: it must be called Props. An interface called AlertProps is ignored unless you also write type Props = AlertProps.
  • interface or type both work: use type when you need unions or intersections at the top level.
  • Exporting is optional: export interface Props is allowed but not required.

If passing props is new to you, start with how to pass props to an Astro component.

What the Errors Look Like

Here are real errors from astro check for some deliberate mistakes:

<Alert type="danger">Oops</Alert>
<Alert title="No type">Oops</Alert>
src/pages/errors.astro:8:8 - error ts(2322): Type '"danger"' is not assignable to type '"success" | "info" | "warning" | "error"'.

8 <Alert type="danger">Oops</Alert>
         ~~~~

src/pages/errors.astro:9:2 - error ts(2322): Type '{ children: string; title: string; }' is not assignable to type 'IntrinsicAttributes & Props'.
  Property 'type' is missing in type '{ children: string; title: string; }' but required in type 'Props'.

Notice children: string in the second message. Slot content is represented as a children prop in the type system, which becomes useful below.

Optional Props and Defaults

Mark a prop optional with ? and supply a default while destructuring:

---
interface Props {
  heading?: string;
  limit?: number;
}

const { heading = "Latest posts", limit = 5 } = Astro.props;
---

TypeScript narrows the destructured variables: heading is string, not string | undefined, because the default covers the missing case. Keep defaults in the destructuring rather than in the interface; interfaces can't hold values.

Unions and Discriminated Unions

String literal unions are the simplest way to restrict a prop to known values, as with type on Alert. For components where different props are valid in different modes, use a discriminated union:

---
// src/components/Action.astro
type Props =
  | { mode: "link"; href: string }
  | { mode: "button"; actionId: string };

const props = Astro.props;
---
{props.mode === "link" ? (
  <a href={props.href}><slot /></a>
) : (
  <button type="button" data-action={props.actionId}><slot /></button>
)}

Checking props.mode narrows the type, so props.href is only accessible in the link branch. From the outside, <Action mode="link" actionId="x" /> is an error because a link needs href. Don't destructure before narrowing: const { mode, href } = Astro.props fails because href doesn't exist on the button variant.

Typing Arrays and Objects

Define the item type once and reuse it:

---
// src/components/PostList.astro
type Post = {
  title: string;
  url: string;
  pubDate: Date;
};

interface Props {
  posts: Post[];
  heading?: string;
}

const { posts, heading = "Latest posts" } = Astro.props;
---
<section>
  <h2>{heading}</h2>
  <ul>
    {posts.map((post) => (
      <li>
        <a href={post.url}>{post.title}</a>
        <time datetime={post.pubDate.toISOString()}>
          {post.pubDate.toLocaleDateString("en-GB")}
        </time>
      </li>
    ))}
  </ul>
</section>

If several components share the Post type, move it to a .ts file and import it with import type { Post } from "../types". Astro's TypeScript presets enable verbatimModuleSyntax, which requires the type keyword for type-only imports.

Typing Slots and Children

Two common patterns come straight from Astro's documentation:

---
// src/components/Card.astro
// The default slot is required
interface Props {
  children: any;
}
---
<div class="card"><slot /></div>
---
// src/components/Divider.astro
// No props and no slot content allowed
type Props = Record<string, never>;
---
<hr class="divider" />

With these, <Card /> with no children and <Divider thick /> are both errors:

src/pages/errors.astro:10:2 - error ts(2322): Type '{}' is not assignable to type 'IntrinsicAttributes & Props'.
  Property 'children' is missing in type '{}' but required in type 'Props'.

src/pages/errors.astro:11:10 - error ts(2322): Type 'true' is not assignable to type 'never'.

Named slots aren't typed this way. Use Astro.slots.has() at runtime to check for them, as described in what slots are and how to use named slots.

Wrapping HTML Elements with HTMLAttributes

HTMLAttributes<Tag> from astro/types contains every valid attribute for an element. Extend it so a wrapper component accepts the same attributes as the element it renders:

---
// src/components/Link.astro
import type { HTMLAttributes } from "astro/types";

interface Props extends HTMLAttributes<"a"> {
  href: string;
  external?: boolean;
}

const { href, external = false, ...attrs } = Astro.props;
---
<a
  href={href}
  target={external ? "_blank" : undefined}
  rel={external ? "noopener noreferrer" : undefined}
  {...attrs}
>
  <slot />
</a>
<Link href="https://astro.build" external class="nav-link" aria-label="Astro website">Astro</Link>

Callers get autocomplete for class, aria-*, data-* and every anchor attribute, and invalid values are caught:

<Link href="/x" target={42}>x</Link>
error ts(2322): Type 'number' is not assignable to type 'HTMLAttributeAnchorTarget | null | undefined'.

Here href is redeclared as required, tightening the optional href from HTMLAttributes<"a">.

Reusing Props with ComponentProps

ComponentProps<typeof Component> gives you the props type of any component, even if its Props isn't exported:

---
// src/pages/index.astro
import type { ComponentProps } from "astro/types";
import Alert from "../components/Alert.astro";

type AlertProps = ComponentProps<typeof Alert>;

const saved: AlertProps = { type: "success", title: "Saved" };
---
<Alert {...saved} dismissible>Your changes were saved.</Alert>

Its most useful job is in wrapper components and layouts that pass props through. An ArticleLayout can accept everything BaseLayout accepts plus its own fields:

---
import type { ComponentProps } from "astro/types";
import BaseLayout from "./BaseLayout.astro";

type Props = ComponentProps<typeof BaseLayout> & {
  author: string;
  pubDate: Date;
};

const { author, pubDate, ...baseProps } = Astro.props;
---
<BaseLayout {...baseProps}>
  <slot />
</BaseLayout>

This pattern is covered in full in how to nest layouts in Astro.

Components That Change Their Tag

Sometimes a component should render as different elements, such as a heading that can be an <h1> or an <h3>. The simplest typed approach restricts the tag to a small union:

---
// src/components/Heading.astro
import type { HTMLAttributes } from "astro/types";

interface Props extends HTMLAttributes<"h2"> {
  as?: "h1" | "h2" | "h3" | "h4";
  size?: "sm" | "md" | "lg";
}

const { as: Tag = "h2", size = "md", class: className, ...attrs } = Astro.props;
---
<Tag class:list={["heading", `heading-${size}`, className]} {...attrs}>
  <slot />
</Tag>

For a component that can be any element, with attributes that change to match, Astro provides Polymorphic and HTMLTag. In my testing with Astro 7.4.2 and @astrojs/check 0.9.12, the documentation's version, which destructures as: Tag directly, failed astro check with "Component 'Tag' is not a valid component", under both TypeScript 5.9 and 6.0. Assigning the tag to a variable with an explicit HTMLTag type passed:

---
// src/components/Box.astro
import type { HTMLTag, Polymorphic } from "astro/types";

type Props<Tag extends HTMLTag> = Polymorphic<{ as: Tag }>;

const { as, ...attrs } = Astro.props;
const Tag = as as HTMLTag;
---
<Tag {...attrs}><slot /></Tag>

The caller side is fully typed: <Box as="a" href="/docs/">Docs</Box> is fine, while <Box as="img" href="/x" /> is an error because images have no href.

Typing Props for Dynamic Routes

Pages receive props from getStaticPaths() rather than from a parent. InferGetStaticPropsType derives the type from what the function returns, so you don't write it twice:

---
// src/pages/posts/[id].astro
import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
import { getCollection } from "astro:content";

export const getStaticPaths = (async () => {
  const posts = await getCollection("blog");
  return posts.map((post) => ({
    params: { id: post.id },
    props: { title: post.data.title, pubDate: post.data.pubDate },
  }));
}) satisfies GetStaticPaths;

type Props = InferGetStaticPropsType<typeof getStaticPaths>;

const { title, pubDate } = Astro.props;
---
<h1>{title}</h1>
<p>{pubDate.getFullYear()}</p>

pubDate is typed as a Date because the collection schema uses z.coerce.date(). There's also InferGetStaticParamsType for Astro.params. Dynamic routes are covered in dynamic routes with getStaticPaths.

Typing Content Collection Entries

When a component receives a whole collection entry, use CollectionEntry from astro:content:

---
// src/components/PostCard.astro
import type { CollectionEntry } from "astro:content";

interface Props {
  post: CollectionEntry<"blog">;
}

const { post } = Astro.props;
---
<a href={`/blog/${post.id}/`}>{post.data.title}</a>

The type follows your schema in src/content.config.ts, so renaming a field there shows up as an error in every component that uses it. Schemas are covered in how to define a collection schema with Zod.

Making Type Errors Fail the Build

astro dev and astro build don't type-check. They strip types and carry on, so a wrong prop only shows up in your editor. To catch errors in CI, install the checker and run it before the build:

npm install -D @astrojs/check typescript
{
  "scripts": {
    "build": "astro check && astro build"
  }
}

astro check checks both .astro and .ts files and exits with a non-zero code when it finds errors, which stops the build. A clean run ends like this:

Result (57 files): 
- 0 errors
- 0 warnings
- 0 hints

For stricter checking, extend astro/tsconfigs/strict or astro/tsconfigs/strictest in your tsconfig.json.


FAQ: Typing Props in Astro

No. Astro finds a type or interface named Props in the component script automatically. Exporting it is allowed but not required. The name must be exactly Props.

Either works. An interface is convenient for extending HTMLAttributes, while a type alias is needed for top-level unions, intersections and Record types.

Import HTMLAttributes from astro/types and extend it, for example extending HTMLAttributes with the "button" tag. Destructure your own props, collect the rest and spread them onto the element.

Use ComponentProps from astro/types with typeof, passing typeof and the component, such as typeof Button. It works even when the other component doesn't export its Props.

The dev server and build strip types without checking them. Run astro check, which needs @astrojs/check and typescript installed, and add it before astro build in your build script.

Add children to Props, such as children: any. Slot content is passed as children in the type system, so using the component with no content becomes a type error.


Conclusion

Typing props in Astro takes one declaration: an interface or type named Props in the component script. From there, optional props and defaults, literal unions and discriminated unions work exactly as they do in any TypeScript code, and children lets you require slot content. The helpers in astro/types cover the common cases: HTMLAttributes for element wrappers, ComponentProps for reusing and forwarding props, and Polymorphic for components that change their tag.

For pages, InferGetStaticPropsType and CollectionEntry keep route and content types in sync with their sources. Since neither the dev server nor the build checks types, add astro check to your build script so a mistyped prop fails in CI instead of in production.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper