Skip to content

TypeScript with Svelte

Add lang="ts" to the script and you’re writing TypeScript. Runes are generic, so state is typed with no ceremony:

<script lang="ts">
let count = $state(0); // inferred number
let name = $state<string>(''); // explicit
let user = $state<User | null>(null);
</script>

The compiler infers the type from the initial value where it can; use $state<T>(...) to be explicit (e.g. when the initial value is null but the type is wider).

Declare an interface for your props and annotate the $props() destructuring. This is the idiomatic Svelte 5 replacement for Svelte 4’s individually-typed export let props:

<script lang="ts">
interface Props {
label: string;
count?: number; // optional
onclick?: () => void; // event callbacks are just props
}
let { label, count = 0, onclick }: Props = $props();
</script>
<button {onclick}>{label}: {count}</button>

Consumers get autocompletion and type-checking when they use the component, and svelte-check flags a wrong or missing prop at the call site.

Snippets passed as props are typed with the Snippet type from svelte. A snippet that takes parameters is Snippet<[ArgType]>:

<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
header: Snippet; // a plain snippet
row: Snippet<[item: string]>; // a snippet taking one argument
children: Snippet; // the default passed-in content
}
let { header, row, children }: Props = $props();
</script>
{@render header()}
{@render row('first')}
{@render children()}

When a component’s types depend on what the consumer passes (a typed list, a generic select), declare type parameters with the generics attribute on the script tag:

<script lang="ts" generics="T">
interface Props {
items: T[];
selected: T;
onselect: (item: T) => void;
}
let { items, selected, onselect }: Props = $props();
</script>

Now T flows through: pass items of User[] and onselect is typed as (item: User) => void. There’s also a Component type (from svelte) for typing a variable that holds a component.

Proving T actually flows through, with a real error

Section titled “Proving T actually flows through, with a real error”

“T flows through” is a specific, checkable claim — so check it. Here’s the generic component from above, and two callers: one that gets the callback’s parameter type right, one that doesn’t.

src/lib/Select.svelte
<script lang="ts" generics="T">
interface Props {
items: T[];
selected: T;
onselect: (item: T) => void;
}
let { items, selected, onselect }: Props = $props();
</script>
<ul>
{#each items as item}
<li class:active={item === selected}>
<button onclick={() => onselect(item)}>{JSON.stringify(item)}</button>
</li>
{/each}
</ul>
src/lib/UseSelectGood.svelte
<script lang="ts">
import Select from './Select.svelte';
interface User { id: number; name: string }
let users: User[] = [{ id: 1, name: 'Ada' }];
let picked = $state<User>(users[0]);
</script>
<!-- T is inferred as User: onselect is typed (item: User) => void -->
<Select items={users} selected={picked} onselect={(u) => { picked = u; console.log(u.name); }} />
src/lib/UseSelectBad.svelte
<!-- @expect-error Property 'toFixed' does not exist on type 'User'. -->
<script lang="ts">
import Select from './Select.svelte';
interface User { id: number; name: string }
let users: User[] = [{ id: 1, name: 'Ada' }];
let picked = $state<User>(users[0]);
</script>
<!-- BUG: treats the callback's item as a number, but T was inferred as User -->
<Select items={users} selected={picked} onselect={(u) => console.log(u.toFixed(2))} />

Real svelte-check output for UseSelectBad.svelte — proof that T was actually inferred as User, not left as unknown or silently allowed to be anything:

src/lib/UseSelectBad.svelte:8:72
Property 'toFixed' does not exist on type 'User'.

.toFixed is a number method; the checker rejecting it on u is direct evidence that u’s type came from items: User[], flowed through T, and landed on onselect’s parameter — not a coincidence and not any.

flowchart LR
  props["interface Props"] --> dollar["let { ... }: Props = props()"]
  snip["Snippet type"] --> dollar
  gen["generics attribute (T)"] --> dollar
  dollar --> check["svelte-check verifies usage + markup"]
Where types flow in a Svelte component

TypeScript in a .svelte file is checked in two passes that have to agree: the <script lang="ts"> block is ordinary TypeScript (whatever tsc would say about it), and the markup is checked by svelte-check’s own understanding of Svelte’s template syntax — an {expression}, a prop passed on a component tag, a {#each} item’s inferred type. generics="T" is what lets a single type parameter flow across that boundary: declared once on the <script> tag, then usable in the Props interface, and inferred at every call site from whatever the caller actually passes — proven above, T isn’t a formality, the checker really does carry it through to a prop several levels removed from where it was inferred.

What you getBenefitCost
interface Props + $props()One typed shape for the whole component surface, checked at every call site (proven above with a real prop-type error)An extra interface declaration per component, versus untyped export let
generics="T"Consumer types flow through automatically — no any, no per-usage casting (proven above)Only one type parameter’s worth of ergonomics; a component genuinely generic over multiple unrelated shapes needs more care in the interface itself
Snippet<[Arg]> for typed slot contentThe renderer and the snippet body share one parameter type, checked by svelte-checkSlightly more ceremony than an untyped Snippet when the content truly takes no arguments
  • “A generic component’s type parameter is just documentation — it doesn’t actually get checked.” Proven above: UseSelectBad.svelte’s real svelte-check error (Property 'toFixed' does not exist on type 'User') only makes sense if T was genuinely inferred as User from items and carried into onselect’s parameter type.
  • “You need as any somewhere to make a generic Svelte component’s callback prop line up.” Not in the proven example above — onselect={(u) => { picked = u; console.log(u.name); }} type-checks cleanly with u correctly typed as User, no cast required.
  • “Typing props is only about catching typos in your own component.” The error proven above is caught at the caller’s markup (UseSelectBad.svelte, not Select.svelte) — the biggest value is catching a wrong usage anywhere the component is consumed, not just inside its own file.

When reviewing typed Svelte components, check for:

  • Does a “generic” component actually declare generics="T" on the <script> tag, or does it just use T in the Props interface without ever introducing it — which fails to compile rather than silently becoming any?
  • Is a prop or callback parameter typed as any (or left untyped, inferring any implicitly) specifically to make a type error go away, rather than fixing the actual shape mismatch svelte-check was reporting?
  • Does a Snippet type omit its parameters (plain Snippet instead of Snippet<[Arg]>) when the snippet is always called with an argument in practice — losing type-checking on {@render row(item)} call sites?
  • Is $state<T>(...)’s explicit generic used to paper over a mismatched initial value, instead of fixing the value or widening the type honestly (e.g. T | null)?

🐛 Spot the bug

<script lang="ts" generics="T">
interface Props {
items: T[];
onselect: (item: any) => void;
}
let { items, onselect }: Props = $props();
</script>
<ul>
{#each items as item}
<li><button onclick={() => onselect(item)}>{JSON.stringify(item)}</button></li>
{/each}
</ul>
Show the answer

items is correctly generic over T, but onselect’s parameter is typed any instead of T — so the one connection that matters (the callback receives exactly the item type the caller put into items) is silently severed. A caller can now pass onselect={(x: number) => x.toFixed(2)} even when items is User[], and nothing catches it: any disables checking for that parameter entirely, which defeats the entire point of making the component generic in the first place (proven earlier in this lesson: with onselect: (item: T) => void, the equivalent mistake is a real, caught svelte-check error).

The fix is exactly the type this lesson used throughout: onselect: (item: T) => void — the same type parameter the rest of Props already uses, not a new escape hatch.

How do you type a piece of state whose initial value does not reveal the full type?
What is the idiomatic way to type component props in Svelte 5?
What type describes a snippet passed as a prop?
How do you write a generic component whose types depend on the consumer's data?
If users is User[] and Select's onselect prop is typed (item: T) => void, what does svelte-check report?
<Select items={users} selected={picked}
  onselect={(u) => console.log(u.toFixed(2))} />
This generic component types onselect's parameter as any instead of T. What is the consequence?
interface Props {
  items: T[];
  onselect: (item: any) => void;
}