TypeScript Tips for Astro Developers: From Basics to Advanced Patterns
Astro has excellent TypeScript support out of the box. But to truly leverage it, you need to understand how TypeScript works within Astro’s unique architecture—where server-side components meet client-side islands.
This guide covers TypeScript patterns that are particularly useful in Astro development, from everyday techniques to advanced type manipulation.
Getting Started with TypeScript in Astro
Astro projects support TypeScript by default. You can use .ts files anywhere and TypeScript in .astro component frontmatter:
---
// TypeScript works here!
interface Post {
title: string;
slug: string;
pubDate: Date;
}
const posts: Post[] = await fetchPosts();
---
<ul>
{posts.map(post => <li>{post.title}</li>)}
</ul>
Configuration
Astro generates a tsconfig.json with sensible defaults:
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"]
}
}
}
The strict preset enables:
strict: truenoImplicitAny: truestrictNullChecks: true- And more safety checks
Component Props Typing
Basic Props Interface
---
interface Props {
title: string;
subtitle?: string; // Optional
count: number;
}
const { title, subtitle, count } = Astro.props;
---
<article>
<h1>{title}</h1>
{subtitle && <h2>{subtitle}</h2>}
<span>{count} items</span>
</article>
Props with Children (Slots)
---
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'section'> {
title: string;
variant?: 'default' | 'highlighted';
}
const { title, variant = 'default', class: className, ...attrs } = Astro.props;
---
<section class:list={[`section-${variant}`, className]} {...attrs}>
<h2>{title}</h2>
<slot />
</section>
Generic Props
---
type Props<T> = {
items: T[];
renderItem: (item: T) => string;
};
const { items, renderItem } = Astro.props as Props<unknown>;
---
<ul>
{items.map(item => <li>{renderItem(item)}</li>)}
</ul>
Content Collections Types
Astro provides robust types for content collections.
Using CollectionEntry
import type { CollectionEntry } from 'astro:content';
// Full collection entry type
type Post = CollectionEntry<'posts'>;
// Just the data (frontmatter)
type PostData = Post['data'];
// Use in components
interface Props {
post: Post;
}
Inferred Types from getStaticPaths
---
import type { GetStaticPaths, InferGetStaticPropsType } from 'astro';
import { getCollection } from 'astro:content';
export const getStaticPaths = (async () => {
const posts = await getCollection('posts');
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
}) satisfies GetStaticPaths;
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { post } = Astro.props;
// post is fully typed!
---
Pagination Types
---
import type { GetStaticPaths, InferGetStaticPropsType, Page } from 'astro';
import type { CollectionEntry } from 'astro:content';
export const getStaticPaths = (async ({ paginate }) => {
const posts = await getCollection('posts');
return paginate(posts, { pageSize: 10 });
}) satisfies GetStaticPaths;
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { page } = Astro.props;
// page is Page<CollectionEntry<'posts'>>
---
Utility Types for Astro
Making Props Optional
// All properties optional
type PartialProps = Partial<Props>;
// Specific properties optional
type WithOptionalSubtitle = Omit<Props, 'subtitle'> & {
subtitle?: string;
};
Pick and Omit
interface FullPost {
title: string;
description: string;
content: string;
author: string;
pubDate: Date;
tags: string[];
}
// Just what we need for a card
type PostCard = Pick<FullPost, 'title' | 'description' | 'pubDate'>;
// Everything except content
type PostMeta = Omit<FullPost, 'content'>;
Record for Dynamic Keys
// Theme variants
type ThemeVariant = 'light' | 'dark' | 'system';
type ThemeColors = Record<ThemeVariant, { bg: string; fg: string }>;
const themes: ThemeColors = {
light: { bg: '#ffffff', fg: '#000000' },
dark: { bg: '#1a1a1a', fg: '#ffffff' },
system: { bg: 'inherit', fg: 'inherit' },
};
Extract and Exclude
type Status = 'draft' | 'review' | 'published' | 'archived';
// Only active statuses
type ActiveStatus = Exclude<Status, 'archived'>;
// Result: 'draft' | 'review' | 'published'
// Only specific statuses
type VisibleStatus = Extract<Status, 'published' | 'archived'>;
// Result: 'published' | 'archived'
Discriminated Unions
Perfect for components that change behavior based on a type:
type Notification =
| { type: 'success'; message: string }
| { type: 'error'; message: string; code: number }
| { type: 'warning'; message: string; dismissable?: boolean };
function renderNotification(notification: Notification) {
switch (notification.type) {
case 'success':
return `Success: ${notification.message}`;
case 'error':
// TypeScript knows 'code' exists here
return `Error ${notification.code}: ${notification.message}`;
case 'warning':
// TypeScript knows 'dismissable' might exist
return notification.dismissable
? `Warning (dismissable): ${notification.message}`
: `Warning: ${notification.message}`;
}
}
In Astro components:
---
type Props =
| { variant: 'link'; href: string }
| { variant: 'button'; onClick?: string };
const props = Astro.props;
---
{props.variant === 'link' ? (
<a href={props.href}><slot /></a>
) : (
<button onclick={props.onClick}><slot /></button>
)}
Type Guards
Custom Type Guards
interface LocalImage {
src: string;
width: number;
height: number;
}
type ImageSource = LocalImage | string;
function isLocalImage(src: ImageSource): src is LocalImage {
return typeof src === 'object' && 'width' in src;
}
// Usage
function getImageWidth(src: ImageSource): number {
if (isLocalImage(src)) {
return src.width; // TypeScript knows it's LocalImage
}
return 0; // String URL, unknown width
}
Assertion Functions
function assertDefined<T>(
value: T | null | undefined,
message?: string
): asserts value is T {
if (value === null || value === undefined) {
throw new Error(message ?? 'Value is not defined');
}
}
// Usage
const maybePost = await getEntry('posts', slug);
assertDefined(maybePost, `Post not found: ${slug}`);
// TypeScript now knows maybePost is defined
console.log(maybePost.data.title);
Generics Patterns
Generic Components
// A generic list component
interface ListProps<T> {
items: T[];
keyExtractor: (item: T) => string;
renderItem: (item: T, index: number) => any;
}
function List<T>({ items, keyExtractor, renderItem }: ListProps<T>) {
return items.map((item, index) => (
<div key={keyExtractor(item)}>
{renderItem(item, index)}
</div>
));
}
Constrained Generics
// Must have an 'id' property
interface Identifiable {
id: string | number;
}
function findById<T extends Identifiable>(items: T[], id: T['id']): T | undefined {
return items.find(item => item.id === id);
}
Default Generic Types
interface PaginatedResponse<T = unknown> {
data: T[];
page: number;
totalPages: number;
totalItems: number;
}
// Can use without specifying type
const response: PaginatedResponse = { ... };
// Or with specific type
const postResponse: PaginatedResponse<Post> = { ... };
Handling External Data
API Response Typing
// Define expected shape
interface ApiPost {
id: number;
title: string;
body: string;
userId: number;
}
async function fetchPosts(): Promise<ApiPost[]> {
const response = await fetch('https://api.example.com/posts');
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
}
return response.json() as Promise<ApiPost[]>;
}
Zod for Runtime Validation
import { z } from 'zod';
const PostSchema = z.object({
id: z.number(),
title: z.string(),
body: z.string(),
userId: z.number(),
});
type Post = z.infer<typeof PostSchema>;
async function fetchAndValidatePosts(): Promise<Post[]> {
const response = await fetch('https://api.example.com/posts');
const data = await response.json();
// Throws if validation fails
return z.array(PostSchema).parse(data);
}
Environment Variables
// src/env.d.ts
interface ImportMetaEnv {
readonly SITE_URL: string;
readonly API_KEY: string;
readonly ENABLE_ANALYTICS: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
Usage with type safety:
const apiKey = import.meta.env.API_KEY;
// TypeScript knows this is string
const enableAnalytics = import.meta.env.ENABLE_ANALYTICS === 'true';
// Convert to boolean
Best Practices
1. Prefer interface for Objects
// Good: Extensible
interface Post {
title: string;
}
interface FeaturedPost extends Post {
featured: true;
}
// Use type for unions, intersections, primitives
type Status = 'draft' | 'published';
type ID = string | number;
2. Use satisfies for Type Checking
// preserves literal types while checking shape
const config = {
theme: 'dark',
language: 'en',
} satisfies Record<string, string>;
// config.theme is 'dark', not string
3. Avoid any, Use unknown
// Bad: Disables type checking
function process(data: any) {
data.whatever(); // No error, even though this might fail
}
// Good: Forces type checking
function process(data: unknown) {
if (typeof data === 'object' && data !== null && 'title' in data) {
console.log(data.title);
}
}
4. Use Const Assertions
// Without const assertion
const themes = ['light', 'dark'];
// type: string[]
// With const assertion
const themes = ['light', 'dark'] as const;
// type: readonly ['light', 'dark']
type Theme = typeof themes[number];
// type: 'light' | 'dark'
Conclusion
TypeScript in Astro provides a powerful development experience when used effectively:
- Type your props - Catch errors at build time
- Leverage inference - Let TypeScript figure out types when obvious
- Use utility types - Transform types instead of duplicating
- Validate external data - Runtime checks complement static types
- Embrace strictness - Strict mode catches more bugs
The patterns in this guide will help you write more robust Astro applications with fewer runtime errors and better IDE support.
Further Reading: