TypeScript in Svelte: typing components, props, and stores without the boilerplate

TypeScript in Svelte: typing components, props, and stores without the boilerplate

Written for Svelte 5.57, SvelteKit 2.70.x, and TypeScript 5.x. If you’re on the SvelteKit 3 release candidate, imports in this article that start with $lib become #lib, a Node subpath import you declare in package.json. Running npx sv migrate sveltekit-3 rewrites them for you (SvelteKit 3 migration guide).

Most Svelte codebases that claim to use TypeScript don’t have real type safety: props get typed as any or left unannotated, stores hold untyped values, and event payloads are whatever the last person passed in. The other failure mode is the opposite: pages of boilerplate copied from Svelte 4 tutorials, complete with createEventDispatcher generics and $$Props declarations that Svelte 5 no longer needs.

Both problems come from the same source. Svelte 5 changed how props and events work, and a lot of the tutorial content online still teaches the old model.

This guide shows the current, low-boilerplate way to type components, props, events, stores, snippets, and generics in Svelte 5, with working code you can paste into a project today. That kind of type discipline is also what a hiring team notices first when reviewing a candidate’s Svelte code.

In this guide:

  • Set up a current Svelte 5 TypeScript project
  • Build a typed Svelte component with $props()
  • Type events, callback props, and snippets
  • Keep stores and reusable components type-safe
  • Avoid common TypeScript friction in Svelte
  • Frequently asked questions
  • Clean type patterns make Svelte work easier to maintain

Set up a current Svelte 5 TypeScript project

Getting the setup right takes about two minutes, and skipping one step here is why some teams end up with TypeScript files that never get type-checked.

Verify the Svelte and TypeScript versions before you start

Check what you have before writing any typed code. Runes ($props, $state, $derived) only exist in Svelte 5.

npm ls svelte # or check package.json

npx tsc –version

If package.json shows “svelte”: “^4.x”, none of the $props() patterns below will compile. Upgrade first, or read the Svelte 4 contrast blocks in this article as your reference instead.

Scaffold the project and configure Svelte-specific TypeScript settings

The current official scaffolding command is npx sv create. The older npm create svelte@latest command still redirects, but sv is the tool Svelte maintains now.

npx sv create my-app

# choose SvelteKit, then select “Yes, using TypeScript syntax”

cd my-app

npm install

npm run dev

For a plain Svelte app without SvelteKit, use npm create vite@latest and pick the svelte-ts template. Both paths add a svelte.config.js with vitePreprocess, which is what turns TypeScript inside your components into JavaScript.

Every component that uses types needs the language attribute on its script tag:

<script lang=”ts”>

 let count: number = 0;

</script>

Without lang=”ts”, your type annotations are syntax errors. SvelteKit generates a .svelte-kit/tsconfig.json that your project config extends, and Svelte’s docs call out settings you should not change:

{

 "extends": "./.svelte-kit/tsconfig.json",

 "compilerOptions": {

   "strict": true,

   "verbatimModuleSyntax": true,

   "isolatedModules": true,

   "moduleResolution": "bundler"

 }

}

isolatedModules and verbatimModuleSyntax matter because each .svelte file compiles on its own. Turn them off, and you get confusing errors around type-only imports.

Use editor tooling and Svelte-check in CI

Install the Svelte VS Code extension for in-editor errors, then wire svelte-check into your pipeline. tsc alone does not read .svelte files, so a project without svelte-check in CI has no type gate at all. 

The Svelte CLI runs the same checker as sv check, with the same –threshold and –output options.

{

 "scripts": {

   "check": "svelte-check --tsconfig ./tsconfig.json",

"check:ci": "svelte-check --tsconfig ./tsconfig.json --threshold error --output machine"

 }

}

# .github/workflows/ci.yml

– run: npm ci

– run: npm run check:ci

This is one of the most common setup mistakes teams make: TypeScript is installed, the editor shows red squiggles, and nothing in the build fails when someone pushes a broken type.

Build a typed Svelte component with $props()

In Svelte 5, you type props by defining an interface and annotating the destructured $props() call. That is the whole pattern.

A working Svelte TypeScript example with typed props

<!-- UserCard.svelte -->

<script lang="ts">

 interface Props {

   name: string;

   email: string;

   role: 'admin' | 'member' | 'viewer';

   lastActive: Date;

 }

 let { name, email, role, lastActive }: Props = $props();

</script>

<article class="card">

 <h3>{name}</h3>

 <p>{email}</p>

 <span class="badge">{role}</span>

 <time datetime={lastActive.toISOString()}>

   {lastActive.toLocaleDateString()}

 </time>

</article>

Pass role=”owner” from a parent and svelte-check fails immediately, because ‘owner’ is not in the union.

Here is the Svelte 4 version of the same component, for contrast:

<!-- Svelte 4: legacy pattern, do not use in Svelte 5 -->

<script lang="ts">

 export let name: string;

 export let email: string;

 export let role: 'admin' | 'member' | 'viewer';

 export let lastActive: Date;

</script>

Svelte 4 typed each export separately, and typing rest props or renaming a prop required an extra $$Props interface. The $props() rune replaces all of that with one destructuring assignment.

Keep optional props and default values type-safe

Mark optional props with ? and give them defaults in the destructuring pattern.

<script lang="ts">

 interface Props {

   label: string;

   variant?: 'primary' | 'ghost';

   disabled?: boolean;

   onclick?: (event: MouseEvent) => void;

 }

 let {

   label,

   variant = 'primary',

   disabled = false,

   onclick

 }: Props = $props();

</script>

<button class={variant} {disabled} {onclick}>{label}</button>

Inside the component, variant narrows to a plain string union because the default removes undefined. Callers still get to omit it.

Use type-only imports for shared models

Type load functions and page data with ./$types

Props are half of TypeScript in a SvelteKit app. The other half is the data your routes load. SvelteKit generates types for every route, and you import them from ./$types, a module that sits next to your route files. The load function and the page then share one definition, with no hand-written interface.

// src/routes/invoices/[id]/+page.server.ts

import { error } from '@sveltejs/kit';

import type { PageServerLoad } from './$types';

import type { Invoice } from '$lib/types';

export const load: PageServerLoad = async ({ params, fetch }) => {

  const res = await fetch(`/api/invoices/${params.id}`);

  if (!res.ok) error(404, 'Invoice not found');

  const invoice: Invoice = await res.json();

  return { invoice };

};

<!-- src/routes/invoices/[id]/+page.svelte -->

<script lang="ts">

  import type { PageProps } from './$types';

  let { data }: PageProps = $props();

</script>

<h1>Invoice {data.invoice.id}</h1>

params.id is typed from the [id] folder name, and data.invoice is typed from what load returns. Rename a field on Invoice and svelte-check flags every template that reads it. PageProps needs SvelteKit 2.16 or later; on older versions, import PageData from ./$types and write let { data }: { data: PageData } = $props();. 

One caveat: await res.json() isn’t checked at runtime, so in production, run the response through a schema first, as shown in the Zod example later in this article. For a full load-function setup against a real content API, see our guide to wiring a headless CMS into SvelteKit.

When several components share a domain type, put it in a .ts file and import it with the type keyword. With verbatimModuleSyntax on, a plain import of a type-only export breaks the build.

// src/lib/types.ts

export interface Invoice {

 id: string;

 amountCents: number;

 status: 'draft' | 'sent' | 'paid';

}

<script lang="ts">

 import type { Invoice } from '$lib/types';

 interface Props {

   invoice: Invoice;

   onPay: (id: string) => void;

 }

 let { invoice, onPay }: Props = $props();

</script>

What Goes Wrong When Props Have No Defined Shape

Write let { user } = $props(); with no annotation, and user becomes any. The component then accepts anything and fails at runtime instead of at check time.

<script lang="ts">

 // Bad: user is `any`, so user.emial compiles fine

 let { user } = $props();

</script>

<p>{user.emial}</p>

That typo ships. With a Props interface, svelte-check catches it in the same second you type it.

If you inherit a folder of untyped components, an AI assistant handles the grunt work well: paste the component plus two or three call sites from parent templates, and ask it to produce a Props interface matching the values actually passed. Review the union types by hand, since the model will guess string where you meant a literal union.

Type events, callback props, and snippets

Svelte 5 replaced component event dispatching with callback props, and this change breaks the most copied tutorial code.

Replace Legacy Event Dispatching With Typed Callback Props

createEventDispatcher is deprecated in Svelte 5. The recommended pattern passes functions down as props, which means the payload type lives in your Props interface with no extra machinery.

Svelte 4, the old way:

<!-- Legacy: Svelte 4 event dispatch -->

<script lang="ts">

 import { createEventDispatcher } from 'svelte';

 const dispatch = createEventDispatcher<{

   select: { id: string; label: string };

 }>();

 function choose(id: string, label: string) {

   dispatch('select', { id, label });

 }

</script>

Svelte 5, the current way:

<!-- Filter.svelte -->

<script lang="ts">

 interface Option {

   id: string;

   label: string;

 }

 interface Props {

   options: Option[];

   onSelect: (option: Option) => void;

   onClear?: () => void;

 }

 let { options, onSelect, onClear }: Props = $props();

</script>

{#each options as option (option.id)}

 <button onclick={() => onSelect(option)}>{option.label}</button>

{/each}

{#if onClear}

 <button onclick={onClear}>Clear</button>

{/if}

The parent now gets full type checking on the handler signature:

<script lang="ts">

 import Filter from './Filter.svelte';

 let selectedId = $state<string | null>(null);

</script>

<Filter

 options={[{ id: 'a', label: 'Active' }]}

 onSelect={(option) => (selectedId = option.id)}

/>

Write onSelect={(option) => option.name} and the compiler rejects it because Option has no name. The old dispatcher gave you CustomEvent<{ id: string; label: string }> and forced every parent to unwrap event.detail.

Type DOM event handlers without unsafe casts

DOM handlers in Svelte 5 use lowercase attributes (onclick, oninput), and the event type comes from the element. The awkward part is event.target, which TypeScript types as EventTarget | null.

<script lang="ts">

 let query = $state('');

 function handleInput(

   event: Event & { currentTarget: EventTarget & HTMLInputElement }

 ) {

   query = event.currentTarget.value;

 }

</script>

<input oninput={handleInput} value={query} />

Using currentTarget gives you the element the handler is attached to, correctly typed. Reaching for (event.target as HTMLInputElement).value works but throws away the check you installed TypeScript for.

Pass and render typed snippets

Snippets replace slots in Svelte 5. You type them with the Snippet interface imported from svelte, and the generic parameter is a tuple of the snippet’s arguments.

<!-- DataList.svelte -->

<script lang="ts">

 import type { Snippet } from 'svelte';

 interface Product {

   sku: string;

   title: string;

   priceCents: number;

 }

 interface Props {

   items: Product[];

   row: Snippet<[Product, number]>;

   empty?: Snippet;

 }

 let { items, row, empty }: Props = $props();

</script>

{#if items.length === 0 && empty}

 {@render empty()}

{:else}

 <ul>

   {#each items as item, i (item.sku)}

     <li>{@render row(item, i)}</li>

   {/each}

 </ul>

{/if}

The parent defines the snippet, and the parameters arrive typed:

<DataList {items}>

 {#snippet row(product, index)}

   <span>{index + 1}. {product.title}</span>

   <strong>${(product.priceCents / 100).toFixed(2)}</strong>

 {/snippet}

 {#snippet empty()}

   <p>No products yet.</p>

 {/snippet}

</DataList>

You never annotate product or index in the parent. The Snippet<[Product, number]> type flows through, and misspelling product.titel fails the check.

Know which Svelte 4 patterns are legacy

Quick reference for anyone porting older code:

  • export let for props: legacy, replaced by $props()
  • $$Props interface: no longer needed; the annotation on $props() covers it
  • createEventDispatcher: deprecated; use callback props
  • on:click directive on DOM elements: legacy, use onclick
  • <slot /> and <slot name=”x” />: legacy, use snippets and {@render}
  • $$restProps: replaced by a rest element in the $props() destructuring

Svelte 5 still runs most of these in legacy mode, so a mixed codebase compiles. New components should use the current syntax, and the official Svelte 5 migration guide covers the automated migration script.

Keep stores and reusable components type-safe

Store creators and generic components both hang on one idea: give TypeScript a type parameter, and inference handles everything downstream.

Create stores with explicit value types

writable, readable, and derived all accept a generic parameter. Supply it, and the $ auto-subscription in your components picks up the type for free.

// src/lib/stores/cart.ts

import { writable, derived, readable, type Writable } from 'svelte/store';

export interface CartLine {

 sku: string;

 quantity: number;

 unitPriceCents: number;

}

export const lines: Writable<CartLine[]> = writable([]);

export const totalCents = derived(lines, ($lines) =>

 $lines.reduce((sum, line) => sum + line.quantity * line.unitPriceCents, 0)

);

export const now = readable<Date>(new Date(), (set) => {

 const id = setInterval(() => set(new Date()), 1000);

 return () => clearInterval(id);

});

In a component, $lines is CartLine[] and $totalCents is number with no extra annotation:

<script lang="ts">

 import { lines, totalCents } from '$lib/stores/cart';

 function addOne(sku: string) {

   lines.update((current) =>

     current.map((line) =>

       line.sku === sku ? { ...line, quantity: line.quantity + 1 } : line

     )

   );

 }

</script>

<p>Total: ${($totalCents / 100).toFixed(2)}</p>

The classic mistake is export const lines = writable([]);. TypeScript infers never[], so pushing a real CartLine fails, and developers “fix” it with writable<any[]>([]). Now every derived store built on top of it inherits any, and the reduce above type-checks even if you misname unitPriceCents.

Runes cover most cross-component state in Svelte 5, so Svelte’s store documentation notes these use cases have shrunk. Stores still fit well for values with subscription logic, like timers or socket feeds.

Rune state takes types the same way. Pass the type as a generic when the initial value doesn’t show the full shape, such as an empty array or null, and annotate $derived when you want a computed value held to a declared type:

interface Tag { id: string; label: string }

let tags = $state<Tag[]>([]);

let selected = $state<Tag | null>(null);

let total: number = $derived(tags.length);

Without the generic, $state([]) infers never[], the same trap as an untyped writable([]).

Use discriminated unions for async state

Loading state is where untyped stores cause real bugs. A discriminated union makes the impossible states unrepresentable.

import { writable } from 'svelte/store';

export type RequestState<T> =

 | { status: 'idle' }

 | { status: 'loading' }

 | { status: 'success'; data: T }

 | { status: 'error'; message: string };

export interface Report {

 id: string;

 rows: number;

}

export const report = writable<RequestState<Report>>({ status: 'idle' });

{#if $report.status === 'success'}

 <p>{$report.data.rows} rows</p>

{:else if $report.status === 'error'}

 <p>{$report.message}</p>

{/if}

Inside the success branch, data exists. Outside it, touching $report.data fails the check, which kills the whole class of “cannot read property of undefined” render errors.

Write generic components without losing type inference

Svelte 5 supports a generics attribute on the script tag. It takes the same text you would put between angle brackets in a TypeScript function signature.

<!-- List.svelte -->

<script lang="ts" generics="T">

 import type { Snippet } from 'svelte';

 interface Props {

   items: T[];

   getKey: (item: T) => string;

   row: Snippet<[T]>;

 }

 let { items, getKey, row }: Props = $props();

</script>

<ul>

 {#each items as item (getKey(item))}

   <li>{@render row(item)}</li>

 {/each}

</ul>

Use it with any data type, and the snippet parameter stays specific:

<script lang="ts">

 import List from './List.svelte';

 const users = [{ id: 'u1', name: 'Ada' }];

</script>

<List items={users} getKey={(u) => u.id}>

 {#snippet row(user)}

   <span>{user.name}</span>

 {/snippet}

</List>

user is { id: string; name: string }. Drop the generics attribute and type items as unknown[], and every parent has to cast inside the snippet.

Apply generic constraints to protect component APIs

Constraints let you require structure while staying generic. This version guarantees every item has an id, so the component supplies its own key function.

<!-- KeyedList.svelte -->

<script lang="ts" generics="T extends { id: string }">

 import type { Snippet } from 'svelte';

 interface Props {

   items: T[];

   row: Snippet<[T]>;

 }

 let { items, row }: Props = $props();

</script>

<ul>

 {#each items as item (item.id)}

   <li>{@render row(item)}</li>

 {/each}

</ul>

Pass an array of objects without id and the parent fails to compile, with an error pointing at the missing property. Multiple parameters work too: generics=”T extends { id: string }, K extends keyof T”.

Avoid common TypeScript friction in Svelte

Most day-to-day type friction in Svelte comes from four places: confusing compile-time types with runtime checks, unhandled nullables, config mismatches, and half-finished migrations.

Separate compile-time types from runtime validation

Types vanish at build time. An API response typed as Invoice is a promise you made to the compiler, and nothing verifies it at runtime.

// Wrong: a cast, not a check

const invoice = (await res.json()) as Invoice;

// Right: validate, then the type is earned

import { z } from 'zod';

const InvoiceSchema = z.object({

 id: z.string(),

 amountCents: z.number(),

 status: z.enum(['draft', 'sent', 'paid'])

});

export type Invoice = z.infer<typeof InvoiceSchema>;

export async function loadInvoice(id: string): Promise<Invoice> {

 const res = await fetch(`/api/invoices/${id}`);

 return InvoiceSchema.parse(await res.json());

}

Now the type and the runtime check come from one definition, so they cannot drift apart.

Use type narrowing for nullable data and union values

With strict on, nullable values need a guard before use. Svelte templates give you a natural place for that.

<script lang="ts">

 interface Props {

   user: { name: string; avatarUrl: string | null } | null;

 }

 let { user }: Props = $props();

</script>

{#if user}

 <h2>{user.name}</h2>

 {#if user.avatarUrl}

   <img src={user.avatarUrl} alt={user.name} />

 {/if}

{:else}

 <p>Sign in to continue.</p>

{/if}

Skip the guard and reach for user!.name, and you have traded a compile error for a runtime crash on the first null response.

Fix common configuration and import errors

A short list of errors that cost teams the most time:

  • “Cannot find module ‘./Thing.svelte'”: your tsconfig.json is missing the SvelteKit extends, or svelte.config.js lost vitePreprocess.
  • “Type-only import must use the type keyword”: verbatimModuleSyntax is on and you wrote a plain import for a type. Add import type.
  • “$props is not defined”: the project is still on Svelte 4, or the file is missing lang=”ts” and the runes are being parsed as plain identifiers.
  • svelte-check passes locally, CI fails: different Node or dependency versions. Run npm ci in CI rather than npm install.
  • any spreading quietly: a single untyped store or as any cast propagates through every derived value. Run svelte-check with –threshold error and treat new any in review as a change request.

Reaching for as to silence an error hides the mismatch instead of fixing it. When you need a temporary escape hatch, unknown plus a narrowing check is safer, since it forces a check at the point of use.

Migrate incrementally from Svelte 4 syntax

Svelte 5 runs legacy components, so you can migrate file by file. Start with npx sv migrate svelte-5, which converts export let, on:click, and slots wherever the change is mechanical. 

Then finish event dispatchers and named slots by hand, turning each into a typed callback prop or Snippet prop as shown above. The official Svelte 5 migration guide lists every change, and our guide to Svelte 5 and runes covers a migration order that keeps reactivity intact. 

If an AI assistant does the dispatcher conversions, check the payload types yourself, since models tend to widen literal unions into string.

Once your components are typed, that’s exactly the kind of engineering discipline worth screening for when you’re hiring; our roundup of where to find vetted Svelte developers covers what else to look for beyond framework familiarity.

Frequently asked questions

Does Svelte support TypeScript out of the box?

Yes, both the SvelteKit and plain Vite scaffolding tools offer a TypeScript option during setup, and Svelte’s compiler handles .svelte files with lang=”ts” script tags natively. The tooling still needs svelte-check to actually type-check .svelte files in CI, since tsc alone doesn’t read them.

How do you type props in Svelte 5?

Define an interface or type describing the prop shape, then annotate the destructured $props() call with it: let { name, email }: Props = $props();. This replaces Svelte 4’s pattern of typing each export let individually and needing a separate $$Props interface for renamed or rest props.

What replaced createEventDispatcher in Svelte 5?

Callback props. Instead of dispatching a named event that a parent listens for with on:eventname, a Svelte 5 component accepts a function as a prop (like onSelect: (option: Option) => void) and calls it directly. createEventDispatcher is deprecated, and the callback-prop pattern gives the parent full type checking on the handler signature without unwrapping a CustomEvent.detail.

How do you type a Svelte store?

Give the store creator function (writable, readable, or derived) an explicit generic type parameter, either directly or by typing the initial value. A component’s $ auto-subscription then infers that type automatically, with no extra annotation needed at the point of use. Skipping this, for example, writing writable([]), causes TypeScript to infer an unusable type like never[], which is why teams end up reaching for any to make the errors go away.

How do you create a generic, reusable component in Svelte?

Add a generics attribute to the component’s script tag, using the same syntax you’d put between angle brackets in a TypeScript function signature, for example generics=”T” or generics=”T extends { id: string }”. The component’s props can then reference that type parameter, and TypeScript infers the concrete type from whatever data the parent actually passes in.

Do Svelte snippets support TypeScript?

Yes. Snippets are typed with the Snippet interface imported from svelte, using a generic tuple for the snippet’s parameters, for example row: Snippet<[Product, number]>. The parent defining the snippet doesn’t need to annotate its parameters manually since the type flows through from the prop declaration.

Clean type patterns make Svelte work easier to maintain

Typing Svelte well in 2026 comes down to a small set of patterns. Define a Props interface and annotate the $props() destructuring. Pass callbacks as typed props instead of dispatching events. Give writable and derived explicit generic parameters. Type snippets with Snippet<[T]>, and add generics=”T” to the script tag when a component should work with any data shape.

Then enforce it. svelte-check running in CI with an error threshold is what separates a codebase that stays typed from one that quietly fills up with any over six months.

If your team needs Svelte code that stays type-safe as it grows, not just files with a lang=”ts” attribute, you need engineers who treat typing as part of the component design. 

Arc pre-vets Svelte developers for technical depth and English fluency before you see a profile, and HireAI returns a shortlist matched to your requirements in seconds. 

Hire vetted Svelte developers with Arc →

Written by
The Arc Team