Skip to main content

How to Use Svelte with TypeScript

Svelte compiles your components to vanilla JavaScript at build time. That compilation step gives you something most frameworks can't match: TypeScript errors in your templates show up the moment you save the file, not when users click a button in production. A typo in a prop name? The compiler catches it. Wrong type passed to a child component? Build fails before the code ships.

Svelte 5 improves on this with runes, reactive primitives that TypeScript understands natively. The $props() rune gives you typed component APIs without runtime overhead. The $state() rune creates reactive values with full type inference. The $derived() rune computes values from dependencies, and TypeScript tracks every transformation. This guide shows you how to use TypeScript with Svelte applications, from configuring your project to handling Svelte 5's runes and reactive patterns.

Setting Up a Svelte Project with TypeScript

If you're starting fresh, you have two main options: a minimal Svelte app with Vite, or a full-featured SvelteKit application.

For a simple Svelte app:

npm create vite@latest my-app -- --template svelte-ts
cd my-app
npm install
npm run dev

For a full-stack SvelteKit application with routing and server-side rendering:

npm create svelte@latest my-app
cd my-app
npm install
npm run dev

When using the SvelteKit scaffolding tool, you'll be prompted to enable TypeScript. Choose "Yes, using TypeScript syntax" for full type checking.

Both setups create a tsconfig.json configured for Svelte. The key compiler options you need:

{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"noEmit": true
}
}

The verbatimModuleSyntax and isolatedModules options matter because Svelte's compiler doesn't support TypeScript features that require cross-file analysis. Each component file is compiled independently, so you can't use features like const enum or namespace merging.

To get type checking and error highlighting in your editor, install the Svelte VS Code extension. For command-line type checking (useful in CI), use svelte-check:

npm install --save-dev svelte-check
npx svelte-check

Typing Component Props with the $props Rune

Svelte 5 introduces runes, a new way to declare reactive state and props. The $props() rune replaces the old export let syntax and works naturally with TypeScript.

Here's how you type props using an interface:

<script lang="ts">
interface UserCardProps {
userId: number;
displayName: string;
avatarUrl?: string;
isOnline?: boolean;
}

let { userId, displayName, avatarUrl, isOnline = false }: UserCardProps = $props();
</script>

<div class="user-card">
<img src={avatarUrl ?? '/default-avatar.png'} alt={displayName} />
<h3>{displayName}</h3>
{#if isOnline}
<span class="status-indicator">Online</span>
{/if}
</div>

You can also use inline type annotations if you prefer:

<script lang="ts">
let {
userId,
displayName,
avatarUrl,
isOnline = false
}: {
userId: number;
displayName: string;
avatarUrl?: string;
isOnline?: boolean;
} = $props();
</script>

The interface approach is cleaner when you have multiple props or want to reuse the type definition.

For components that accept arbitrary additional props, you can use the rest pattern with an index signature:

<script lang="ts">
import type { Snippet } from 'svelte';

interface ButtonProps {
label: string;
onClick: (event: MouseEvent) => void;
variant?: 'primary' | 'secondary' | 'danger';
icon?: Snippet;
[key: string]: unknown; // Allow arbitrary HTML attributes
}

let {
label,
onClick,
variant = 'primary',
icon,
...htmlAttributes
}: ButtonProps = $props();
</script>

<button
class="btn btn-{variant}"
onclick={onClick}
{...htmlAttributes}
>
{#if icon}
{@render icon()}
{/if}
{label}
</button>

This pattern lets you pass any valid HTML attribute to the underlying <button> element while keeping the component-specific props type-safe.

Creating Generic Components in Svelte

Generics let you build reusable components that work with different data types. Svelte 5 supports generics through the generics attribute on the script tag.

Here's a type-safe data table component:

<script lang="ts" generics="T extends { id: string | number }">
// The constraint ensures all items have an id property
// This is useful for consistent data handling across different item types
interface DataTableProps {
items: T[];
columns: Array<{
key: keyof T;
label: string;
width?: string;
}>;
onRowClick?: (item: T) => void;
}

let { items, columns, onRowClick }: DataTableProps = $props();
</script>

<table>
<thead>
<tr>
{#each columns as column}
<th style:width={column.width}>{column.label}</th>
{/each}
</tr>
</thead>
<tbody>
{#each items as item}
<tr onclick={() => onRowClick?.(item)}>
{#each columns as column}
<td>{item[column.key]}</td>
{/each}
</tr>
{/each}
</tbody>
</table>

<style>
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
tr:hover {
background-color: #f9fafb;
cursor: pointer;
}
</style>

You can use this component with any type that has an id property:

<script lang="ts">
import DataTable from './DataTable.svelte';

interface Product {
id: number;
name: string;
price: number;
stock: number;
}

const products: Product[] = [
{ id: 1, name: 'Laptop', price: 999, stock: 5 },
{ id: 2, name: 'Mouse', price: 29, stock: 150 },
{ id: 3, name: 'Keyboard', price: 79, stock: 45 }
];

function handleProductClick(product: Product) {
console.log('Clicked:', product.name);
}
</script>

<DataTable
items={products}
columns={[
{ key: 'name', label: 'Product Name', width: '40%' },
{ key: 'price', label: 'Price', width: '30%' },
{ key: 'stock', label: 'In Stock', width: '30%' }
]}
onRowClick={handleProductClick}
/>

TypeScript ensures that column.key can only be a valid property of your item type. If you try to reference a non-existent property, you'll get a compile-time error.

Event Handlers and Callbacks

Svelte 5 removed the on:click directive in favor of regular properties like onclick. This change makes event handling more consistent with TypeScript.

For standard DOM events, TypeScript knows the event types automatically:

<script lang="ts">
let searchQuery = $state('');

function handleSearch(event: SubmitEvent) {
event.preventDefault(); // Prevent page reload
const form = event.currentTarget as HTMLFormElement;
const formData = new FormData(form);
const query = formData.get('query') as string;
// Perform search...
}

function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
searchQuery = target.value;
}

function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
searchQuery = '';
}
}
</script>

<form onsubmit={handleSearch}>
<input
type="text"
name="query"
value={searchQuery}
oninput={handleInput}
onkeydown={handleKeydown}
placeholder="Search products..."
/>
<button type="submit">Search</button>
</form>

For custom callback props (replacing the old on:customEvent pattern), you define them as regular function props:

<script lang="ts">
interface ModalProps {
isOpen: boolean;
title: string;
onClose: () => void;
onConfirm: (data: { confirmed: boolean; reason?: string }) => void;
}

let { isOpen, title, onClose, onConfirm }: ModalProps = $props();

function handleConfirm() {
onConfirm({ confirmed: true });
onClose();
}

function handleCancel() {
onConfirm({ confirmed: false, reason: 'User cancelled' });
onClose();
}
</script>

{#if isOpen}
<div class="modal-backdrop" onclick={onClose}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<h2>{title}</h2>
<div class="actions">
<button onclick={handleCancel}>Cancel</button>
<button onclick={handleConfirm}>Confirm</button>
</div>
</div>
</div>
{/if}

This approach gives you full type safety for both the callback parameters and return values.

Advanced TypeScript Patterns with Runes

Svelte 5's runes provide reactive primitives that work seamlessly with TypeScript. Here's how to type them effectively.

Reactive State with $state

The $state rune declares reactive state. TypeScript infers the type from the initial value, but you can be explicit when needed:

<script lang="ts">
interface FormData {
email: string;
password: string;
rememberMe: boolean;
}

// Type inference works
let count = $state(0); // TypeScript knows this is number

// Explicit typing for complex objects
let formData = $state<FormData>({
email: '',
password: '',
rememberMe: false
});

// For nullable values
let selectedUser = $state<User | null>(null);

function handleLogin() {
// TypeScript knows formData has email, password, rememberMe
console.log('Logging in:', formData.email);
}
</script>

Derived Values with $derived

Use $derived to compute values that update automatically when their dependencies change:

<script lang="ts">
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}

let cartItems = $state<CartItem[]>([
{ id: 1, name: 'Laptop', price: 999, quantity: 1 },
{ id: 2, name: 'Mouse', price: 29, quantity: 2 }
]);

// TypeScript infers the return type
let totalItems = $derived(
cartItems.reduce((sum, item) => sum + item.quantity, 0)
);

let totalPrice = $derived(
cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0)
);

let formattedTotal = $derived(
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(totalPrice)
);
</script>

<div class="cart-summary">
<p>Items: {totalItems}</p>
<p>Total: {formattedTotal}</p>
</div>

Side Effects with $effect

The $effect rune runs code whenever its reactive dependencies change. It automatically tracks any $state or $derived values you read synchronously:

<script lang="ts">
let theme = $state<'light' | 'dark'>('light');
let fontSize = $state(16);

// TypeScript knows what reactive values are tracked
$effect(() => {
// This effect re-runs when theme or fontSize changes
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.style.fontSize = `${fontSize}px`;

// Cleanup function
return () => {
document.documentElement.classList.remove('dark');
};
});
</script>

Note that $effect doesn't track values read asynchronously. If you read reactive state inside a setTimeout, fetch, or any Promise callback, those won't trigger re-runs.

Bindable Props with $bindable

Use $bindable() to create two-way bound props:

<script lang="ts">
interface SliderProps {
value?: number;
min?: number;
max?: number;
step?: number;
}

let {
value = $bindable(0),
min = 0,
max = 100,
step = 1
}: SliderProps = $props();
</script>

<input
type="range"
bind:value={value}
{min}
{max}
{step}
/>
<output>{value}</output>

The parent can bind to this prop:

<script lang="ts">
import Slider from './Slider.svelte';

let volume = $state(50);
</script>

<Slider bind:value={volume} min={0} max={100} />
<p>Volume: {volume}%</p>

Working with Stores in TypeScript

Svelte 5 encourages using runes for local state, but stores work well for global state management and compatibility with existing libraries.

TypeScript gives you strong support for typing stores:

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

export interface User {
id: number;
username: string;
email: string;
preferences: {
theme: 'light' | 'dark';
notifications: boolean;
};
}

// Writable store with explicit type
export const currentUser: Writable<User | null> = writable(null);

// Derived store - TypeScript infers the type
export const userTheme = derived(
currentUser,
$user => $user?.preferences.theme ?? 'light'
);

// Readable store for values that can't be set externally
export const serverTime: Readable<Date> = readable(new Date(), (set) => {
const interval = setInterval(() => {
set(new Date());
}, 1000);

return () => clearInterval(interval);
});

Using stores in components with TypeScript:

<script lang="ts">
import { currentUser, userTheme } from './stores';
import type { User } from './stores';

// Svelte auto-subscribes with $ prefix
// TypeScript knows $currentUser is User | null
// Use $derived for reactive computations in Svelte 5
const greeting = $derived(
$currentUser
? `Welcome back, ${$currentUser.username}!`
: 'Please log in'
);

function updateTheme(theme: 'light' | 'dark') {
currentUser.update(user => {
if (!user) return user;
return {
...user,
preferences: {
...user.preferences,
theme
}
};
});
}
</script>

<div class="header" class:dark={$userTheme === 'dark'}>
<h1>{greeting}</h1>
{#if $currentUser}
<button onclick={() => updateTheme('dark')}>Dark Mode</button>
{/if}
</div>

For custom stores with additional methods, use TypeScript interfaces:

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

interface Todo {
id: number;
text: string;
completed: boolean;
}

interface TodoStore extends Writable<Todo[]> {
addTodo: (text: string) => void;
toggleTodo: (id: number) => void;
removeTodo: (id: number) => void;
}

function createTodoStore(): TodoStore {
const { subscribe, set, update } = writable<Todo[]>([]);

return {
subscribe,
set,
update,
addTodo: (text: string) => {
update(todos => [
...todos,
{ id: Date.now(), text, completed: false }
]);
},
toggleTodo: (id: number) => {
update(todos =>
todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
},
removeTodo: (id: number) => {
update(todos => todos.filter(todo => todo.id !== id));
}
};
}

export const todos = createTodoStore();

Type-Safe API Integration

When fetching data from APIs, TypeScript helps prevent runtime errors from unexpected response shapes. Here's a pattern using utility types. For data fetching with caching and automatic refetching, TanStack Query works with Svelte and includes full TypeScript support.

<script lang="ts">
interface ApiResponse<T> {
data: T;
error?: string;
status: number;
}

interface Product {
id: number;
name: string;
description: string;
price: number;
inStock: boolean;
}

type ProductListState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'success'; products: Product[] };

let state = $state<ProductListState>({ status: 'idle' });

async function loadProducts() {
state = { status: 'loading' };

try {
const response = await fetch('/api/products');

if (!response.ok) {
state = {
status: 'error',
error: `HTTP ${response.status}: ${response.statusText}`
};
return;
}

// Note: Type assertion doesn't provide runtime validation
// For production apps, use Zod or similar for runtime type checking
const json: ApiResponse<Product[]> = await response.json();

if (json.error) {
state = { status: 'error', error: json.error };
} else {
state = { status: 'success', products: json.data };
}
} catch (error) {
state = {
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}

$effect(() => {
loadProducts();
});
</script>

{#if state.status === 'loading'}
<p>Loading products...</p>
{:else if state.status === 'error'}
<p class="error">Error: {state.error}</p>
{:else if state.status === 'success'}
<ul>
{#each state.products as product}
<li>
<h3>{product.name}</h3>
<p>{product.description}</p>
<p>${product.price}</p>
{#if !product.inStock}
<span class="out-of-stock">Out of stock</span>
{/if}
</li>
{/each}
</ul>
{/if}

This pattern uses discriminated unions to ensure you can only access products when status is 'success', and only access error when status is 'error'. TypeScript prevents you from writing code that tries to render state.products while still loading.

For SvelteKit applications, load functions provide automatic type inference:

// src/routes/products/+page.ts
import type { PageLoad } from './$types';

export const load: PageLoad = async ({ fetch }) => {
const response = await fetch('/api/products');
const products: Product[] = await response.json();

return {
products
};
};
<!-- src/routes/products/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types';

// TypeScript automatically knows data.products is Product[]
let { data }: { data: PageData } = $props();
</script>

<h1>Products</h1>
<ul>
{#each data.products as product}
<li>{product.name}</li>
{/each}
</ul>

SvelteKit generates the PageData type automatically based on your load function's return value. You don't need to manually type it.

Common Pitfalls

TypeScript Features That Don't Work in Svelte

Svelte's compiler has limitations because each component is compiled independently. These TypeScript features won't work:

// ❌ Enums don't work - they require runtime code generation
enum Status {
Loading,
Success,
Error
}

// ✅ Use union types for simple cases
type Status = 'loading' | 'success' | 'error';

// ✅ Or use const objects when you need both type and values
const Status = {
Loading: 'loading',
Success: 'success',
Error: 'error'
} as const;
type Status = typeof Status[keyof typeof Status];
// Now you can use Status.Loading, Status.Success, etc.

// ❌ Decorators aren't supported
class MyComponent {
@observable value = 0;
}

// ✅ Use Svelte's reactive primitives
let value = $state(0);

// ❌ The 'using' keyword isn't supported
using resource = getResource();

// ✅ Use $effect with cleanup instead
$effect(() => {
const resource = getResource();
return () => resource.cleanup();
});

Forgetting lang="ts" in Script Tags

This is easy to miss but breaks type checking:

<!-- ❌ TypeScript won't check this -->
<script>
let count: number = $state(0);
</script>

<!-- ✅ Now TypeScript works -->
<script lang="ts">
let count: number = $state(0);
</script>

Without lang="ts", your IDE and svelte-check will ignore type errors in that component.

Type Inference Limitations with $props

TypeScript can't infer prop types from default values because $props() is the left side of an assignment:

<script lang="ts">
// ❌ TypeScript doesn't know what props are expected
let { name = 'Anonymous', age = 0 } = $props();

// ✅ Always type your props explicitly
let { name = 'Anonymous', age = 0 }: { name: string; age: number } = $props();
</script>

Strict Mode "Possibly Null" Errors

With strict TypeScript, you'll encounter "possibly null" errors when accessing DOM elements:

<script lang="ts">
let inputRef: HTMLInputElement;

function focusInput() {
// ❌ Error: Object is possibly undefined
inputRef.focus();

// ✅ Use optional chaining
inputRef?.focus();

// ✅ Or check explicitly
if (inputRef) {
inputRef.focus();
}
}
</script>

<input bind:this={inputRef} />

When to Use Optional Chaining

TypeScript's strict mode catches potential null/undefined errors, but knowing when to use optional chaining (?.) versus explicit checks can be tricky:

<script lang="ts">
interface UserCardProps {
user?: User; // Optional user
onEdit: (user: User) => void; // Required callback
}

let { user, onEdit }: UserCardProps = $props();

// ✅ Use optional chaining for potentially undefined values
const displayName = user?.profile?.displayName ?? 'Anonymous';

// ✅ Callbacks marked as optional should use optional chaining
// onClose?.(); // If onClose was optional

// ❌ Don't use optional chaining for required props
// onEdit?.(user); // Wrong - onEdit is required

// ✅ Type narrowing makes optional chaining unnecessary
function handleEdit() {
if (user) {
onEdit(user); // TypeScript knows user is defined here
}
}
</script>

The rule of thumb: use optional chaining when a value might be undefined and you want a safe fallback. Use explicit checks when you need to handle the undefined case differently.

Prop Spreading Type Mismatches

When spreading props, watch for type incompatibilities:

<script lang="ts">
interface ButtonProps {
label: string;
[key: string]: unknown; // Allows any additional props
}

let { label, ...rest }: ButtonProps = $props();
</script>

<!-- ✅ This works - HTMLAttributes match [key: string]: unknown -->
<button {...rest}>{label}</button>

If you get type errors about incompatible properties, you might need to use type assertions:

<button {...(rest as any)}>{label}</button>

Or better, use Omit<T> to exclude problematic properties:

<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements';

interface ButtonProps extends Omit<HTMLButtonAttributes, 'type'> {
label: string;
variant: 'primary' | 'secondary';
}

let { label, variant, ...htmlProps }: ButtonProps = $props();
</script>

<button class="btn-{variant}" {...htmlProps}>{label}</button>

Building Type-Safe Svelte Applications with Convex

For applications that need a backend, Convex gives you end-to-end type safety that works well with Svelte and TypeScript. Your database schema, queries, and mutations are all typed automatically.

Here's a type-safe task management example:

// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';

export default defineSchema({
tasks: defineTable({
text: v.string(),
completed: v.boolean(),
userId: v.string(),
createdAt: v.number(),
}).index('by_user', ['userId']),
});

// convex/tasks.ts
import { query, mutation } from './_generated/server';
import { v } from 'convex/values';

export const list = query({
args: { userId: v.string() },
handler: async (ctx, { userId }) => {
return await ctx.db
.query('tasks')
.withIndex('by_user', (q) => q.eq('userId', userId))
.collect();
},
});

export const create = mutation({
args: { text: v.string(), userId: v.string() },
handler: async (ctx, { text, userId }) => {
return await ctx.db.insert('tasks', {
text,
completed: false,
userId,
createdAt: Date.now(),
});
},
});

export const toggle = mutation({
args: { taskId: v.id('tasks') },
handler: async (ctx, { taskId }) => {
const task = await ctx.db.get(taskId);
if (!task) throw new Error('Task not found');

await ctx.db.patch(taskId, { completed: !task.completed });
},
});

Using these typed functions in your Svelte component:

<script lang="ts">
import { useQuery, useMutation } from 'convex-svelte';
import { api } from '../convex/_generated/api';

const userId = 'user_123';

// TypeScript knows the exact return type
const tasks = useQuery(api.tasks.list, { userId });

// Mutations are also fully typed
const createTask = useMutation(api.tasks.create);
const toggleTask = useMutation(api.tasks.toggle);

let newTaskText = $state('');

async function handleAddTask() {
if (!newTaskText.trim()) return;

try {
await createTask({ text: newTaskText, userId });
newTaskText = '';
} catch (error) {
console.error('Failed to create task:', error);
// In production, show error to user
}
}

async function handleToggle(taskId: string) {
try {
await toggleTask({ taskId });
} catch (error) {
console.error('Failed to toggle task:', error);
}
}
</script>

<div class="task-manager">
<form onsubmit={handleAddTask}>
<input
type="text"
bind:value={newTaskText}
placeholder="Add a new task..."
/>
<button type="submit">Add</button>
</form>

{#if $tasks === undefined}
<p>Loading tasks...</p>
{:else if $tasks.length === 0}
<p>No tasks yet. Add one above!</p>
{:else}
<ul>
{#each $tasks as task}
<li>
<input
type="checkbox"
checked={task.completed}
onchange={() => handleToggle(task._id)}
/>
<span class:completed={task.completed}>{task.text}</span>
</li>
{/each}
</ul>
{/if}
</div>

<style>
.completed {
text-decoration: line-through;
opacity: 0.6;
}
</style>

TypeScript catches errors at every level: if you misspell a table name, pass wrong argument types, or try to access properties that don't exist on the returned data, you'll get immediate feedback in your editor.

Building Type-Safe Svelte Applications

Svelte and TypeScript work together to catch errors before runtime. Here are the key patterns to remember:

  • Use the $props() rune with explicit interface types for component props
  • Add lang="ts" to every script tag that uses TypeScript
  • Leverage generic components with the generics attribute for reusable data displays
  • Type your stores explicitly, especially for writable stores with complex data
  • Use discriminated unions for complex state machines and loading states
  • Avoid TypeScript features that require runtime code generation (enums, decorators)
  • Configure verbatimModuleSyntax and isolatedModules in your tsconfig.json
  • Use svelte-check in your CI pipeline to catch type errors before deployment

For more complex patterns, explore these advanced topics:

  • Custom stores with typed methods for domain-specific state management
  • Type-safe context API with getContext and setContext
  • Form validation with Zod or similar schema libraries
  • Component testing with Vitest and typed test utilities

Need a type-safe backend that works seamlessly with Svelte? Get started with Convex to build full-stack TypeScript applications with automatic type generation.