Skip to content
TechPulse Blog
Go back

TypeScript Tips for Astro Developers: From Basics to Advanced Patterns

By Sarah Chen

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:

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:

  1. Type your props - Catch errors at build time
  2. Leverage inference - Let TypeScript figure out types when obvious
  3. Use utility types - Transform types instead of duplicating
  4. Validate external data - Runtime checks complement static types
  5. 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:


Share this post:

Next Post
Welcome to TechPulse Blog