TypeScript with Svelte
Turning on TypeScript
Section titled “Turning on TypeScript”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).
Typing props
Section titled “Typing props”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.
Snippet props and the Snippet type
Section titled “Snippet props and the Snippet type”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()}Generic components
Section titled “Generic components”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.
<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><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); }} /><!-- @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:72Property '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"] Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
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 content | The renderer and the snippet body share one parameter type, checked by svelte-check | Slightly more ceremony than an untyped Snippet when the content truly takes no arguments |
Common Misconceptions
Section titled “Common Misconceptions”- “A generic component’s type parameter is just documentation — it doesn’t actually get checked.” Proven above:
UseSelectBad.svelte’s realsvelte-checkerror (Property 'toFixed' does not exist on type 'User') only makes sense ifTwas genuinely inferred asUserfromitemsand carried intoonselect’s parameter type. - “You need
as anysomewhere 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 withucorrectly typed asUser, 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, notSelect.svelte) — the biggest value is catching a wrong usage anywhere the component is consumed, not just inside its own file.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing typed Svelte components, check for:
- Does a “generic” component actually declare
generics="T"on the<script>tag, or does it just useTin thePropsinterface without ever introducing it — which fails to compile rather than silently becomingany? - Is a prop or callback parameter typed as
any(or left untyped, inferringanyimplicitly) specifically to make a type error go away, rather than fixing the actual shape mismatchsvelte-checkwas reporting? - Does a
Snippettype omit its parameters (plainSnippetinstead ofSnippet<[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.