The Overlooked File That Types Your Entire App

Every SvelteKit project generated with TypeScript support contains a file called src/app.d.ts. It is small by default, easy to overlook, and almost completely unexplained in most tutorials. You might have opened it once, seen two empty interfaces, and closed it without a second thought.

That would be a mistake.

app.d.ts is the file where you tell TypeScript what your SvelteKit application fundamentally is at runtime. It defines the shape of the data that flows through your server hooks, through your load functions, through your form actions, and into your components. Without it, TypeScript has no idea what lives in event.locals, what shape your errors take, or what platform APIs are available on the server. With it properly filled out, your entire application gains coherent, end-to-end type safety that follows data from the moment it enters a request to the moment it renders in the browser.

This article covers everything about app.d.ts: why SvelteKit needs a dedicated global declaration file in the first place, what each of its interfaces means and controls, how to build them up correctly for real production applications, and the patterns that scale from a simple personal project to a multi-tenant SaaS with multiple deployment targets.


Why a Dedicated Global Declaration File Exists

To understand why app.d.ts exists, you need to understand SvelteKit’s architecture. SvelteKit is not a runtime library you call. It is a framework that calls your code. You write +page.server.ts, and SvelteKit calls its load function with a LoadEvent argument. You write src/hooks.server.ts, and SvelteKit calls its handle function with a RequestEvent.

The types for those events — LoadEvent, RequestEvent, PageServerLoad, and others — are generated by SvelteKit’s own type system and depend on what your application puts into them.

The event.locals object, for example, exists as a completely open container. SvelteKit creates it for every request, passes it through your hooks, and makes it available in every load function and form action. But SvelteKit itself has no idea what you put in there. You might populate it with a user object from session validation, a featureFlags record from a config service, a tenant object from subdomain resolution, or all of the above.

TypeScript cannot infer any of that. It needs to be told.

SvelteKit resolves this through a mechanism called module augmentation. The framework ships internal interfaces as empty stubs inside its own type definitions. You fill those stubs in by augmenting the App namespace inside src/app.d.ts.

What is a stub
A stub is a placeholder type definition that has no actual properties or methods. SvelteKit's internal types for `event.locals`, `event.platform`, and others are stubs until you augment them in `app.d.ts`. This design allows SvelteKit to be flexible and framework-agnostic while giving you the power to define your application's specific data contracts.

When TypeScript compiles your project, it merges your declarations with SvelteKit’s, and suddenly every event.locals in every file across your entire project carries the type you declared. No imports, no manual type assertions, no as User casts scattered through your codebase.

That is the power of app.d.ts: it is a declaration of the runtime contract your application upholds, and TypeScript uses it to verify that contract everywhere automatically.

Loading diagram...

The diagram above shows the relationship clearly. You declare once in app.d.ts, SvelteKit’s type system picks it up, and it propagates everywhere the framework passes those structures to your code.


Anatomy of the Default File

A freshly scaffolded SvelteKit project gives you this:

// src/app.d.ts

// See https://svelte.dev/docs/kit/types#app-namespace
// for information about these interfaces
declare global {
	namespace App {
		// interface Error {}
		// interface Locals {}
		// interface PageData {}
		// interface PageState {}
		// interface Platform {}
	}
}

export {}

Five commented-out interfaces inside the App namespace, wrapped in a declare global block. The export {} at the bottom is not decoration; it makes TypeScript treat this as a module rather than a script, which is required for declare global to work correctly. If you remove it, the augmentation breaks silently.

Each of those five interfaces corresponds to a specific part of SvelteKit’s runtime data model. Let’s go through them one by one, from the most commonly used to the most specialized.


App.Locals: The Request-Scoped Data Carrier

App.Locals is the most important of the five interfaces for most applications. It types the locals property that lives on RequestEvent, the object your handle hook receives for every incoming request.

The locals object is SvelteKit’s answer to the question: “how do I pass server-side data to my load functions without fetching it twice?” You authenticate the user once in your handle hook, attach the result to locals, and every subsequent +page.server.ts and +layout.server.ts can read it directly from event.locals without making another database or API call.

Here is what a realistic App.Locals declaration looks like for an application with session-based authentication:

// src/app.d.ts

declare global {
	namespace App {
		interface Locals {
			user: {
				id: string
				email: string
				role: 'admin' | 'editor' | 'viewer'
				displayName: string
			} | null
			session: {
				id: string
				expiresAt: Date
			} | null
			requestId: string
		}
	}
}

export {}

This inline shape is fine for getting started and makes the structure immediately visible. In practice, once your User and Session types exist in $lib/types.ts, reference them with import() type expressions instead — otherwise you end up maintaining the same shape in two places:

// src/app.d.ts — preferred in real projects

declare global {
	namespace App {
		interface Locals {
			user: import('$lib/types').User | null
			session: import('$lib/types').Session | null
			requestId: string
		}
	}
}

export {}

The Common Mistakes section at the end of this article covers this pattern in more detail.

With this in place, the handle hook in src/hooks.server.ts can populate those fields with full type checking:

// src/hooks.server.ts

import type { Handle } from '@sveltejs/kit'
import { nanoid } from 'nanoid'
// nanoid - a tiny, secure, URL-friendly, unique string ID generator for JavaScript.

export const handle: Handle = async ({ event, resolve }) => {
	// requestId is always set — no auth needed for this
	event.locals.requestId = nanoid()

	const sessionToken = event.cookies.get('session')

	if (!sessionToken) {
		event.locals.user = null
		event.locals.session = null
		return resolve(event)
	}

	const sessionData = await fetch(`${event.url.origin}/api/sessions/${sessionToken}`, {
		headers: { 'x-internal-key': process.env.INTERNAL_KEY ?? '' }
	}).then((r) => (r.ok ? r.json() : null))

	if (!sessionData) {
		event.locals.user = null
		event.locals.session = null
		return resolve(event)
	}

	event.locals.user = sessionData.user
	event.locals.session = {
		id: sessionData.sessionId,
		expiresAt: new Date(sessionData.expiresAt)
	}

	return resolve(event)
}

TypeScript now enforces every assignment. If you try to set event.locals.user = { id: 123 } (number instead of string), the compiler catches it. If you attempt to read event.locals.permissions (a field you never declared), the compiler rejects it. The entire locals pipeline becomes type-safe without a single import or cast.

In your load functions, reading from locals is equally clean:

// src/routes/dashboard/+layout.server.ts

import { redirect } from '@sveltejs/kit'
import type { LayoutServerLoad } from './$types'

export const load: LayoutServerLoad = async ({ locals }) => {
	// TypeScript knows locals.user is { id, email, role, displayName } | null
	if (!locals.user) {
		redirect(302, '/login')
	}

	// After the redirect guard, TypeScript narrows the type
	// locals.user is now non-null below this point
	return {
		currentUser: locals.user
	}
}

The narrowing after the redirect call is a subtle but important benefit. Because redirect throws (it never returns), TypeScript correctly narrows locals.user from User | null to User after the guard. You get accurate types without manual assertion.


App.PageData: Typing the Data Object in Your Components

App.PageData is less commonly hand-authored, because SvelteKit generates the PageData type for each route automatically from the return type of its load function. When you look at the generated ./$types file in any route directory, you will find a PageData type that reflects exactly what your load returned.

However, App.PageData serves a different purpose: it defines a site-wide baseline for data that is always present on page.data, regardless of which route is active. This is particularly useful when your root layout’s load function always provides certain fields.

Consider an application where the root layout always loads the current user and global navigation configuration:

// src/app.d.ts

declare global {
	namespace App {
		interface Locals {
			user: import('$lib/types').User | null
			requestId: string
		}

		interface PageData {
			// These come from +layout.server.ts at the root and are
			// always present on $page.data across the entire site
			currentUser: import('$lib/types').User | null
			siteConfig: {
				siteName: string
				maintenanceMode: boolean
				featureFlags: Record<string, boolean>
			}
		}
	}
}

export {}

With App.PageData declared this way, page.data in any component across your app carries those fields with correct types, even without importing anything:

<!-- src/lib/components/NavBar.svelte -->
<script>
	import { page } from '$app/state'
</script>

<!-- TypeScript knows page.data.currentUser and page.data.siteConfig exist -->
{#if page.data.currentUser}
	<span>Welcome, {page.data.currentUser.displayName}</span>
{/if}

{#if page.data.siteConfig.maintenanceMode}
	<div class="banner">Scheduled maintenance in progress.</div>
{/if}

The key insight is that App.PageData represents the intersection of what all your pages share. It does not replace per-route types; it augments them. A specific route’s PageData type (from ./$types) will include both the route-specific fields and everything declared in App.PageData.

Do not overuse this. App.PageData should only contain data that genuinely appears on every single page. If you add a field here that your root layout does not actually populate, you have created a type-lie that will confuse anyone reading the code later.


App.Error: Shaping Your Error Payloads

By default, SvelteKit’s error() helper accepts a status code and a message string. The resulting error object has the shape { message: string }. This is enough for simple applications, but production systems often need richer error information: error codes for client-side handling, correlation IDs for log tracing, structured details for user-facing messages in multiple languages.

App.Error lets you declare the shape of every error object that SvelteKit produces and that +error.svelte files receive. The only requirement is that the interface must include message: string, because the framework itself sets that field in several places.

A well-designed error interface for a production API-backed application:

// src/app.d.ts

declare global {
	namespace App {
		interface Error {
			message: string
			code: string // machine-readable error code, e.g. 'AUTH_REQUIRED'
			correlationId?: string // ties the error back to a server log entry
			detail?: string // extended human-readable explanation
		}
	}
}

export {}

When you throw errors in your load functions or server routes, you pass an object matching this shape:

// src/routes/account/+page.server.ts

import { error } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = async ({ locals, fetch }) => {
	if (!locals.user) {
		error(401, {
			message: 'Authentication required',
			code: 'AUTH_REQUIRED',
			correlationId: locals.requestId
		})
	}

	const profile = await fetch(`/api/users/${locals.user.id}/profile`).then((r) => {
		if (!r.ok) {
			error(500, {
				message: 'Failed to load profile data',
				code: 'PROFILE_FETCH_FAILED',
				correlationId: locals.requestId,
				detail: `Upstream responded with ${r.status}`
			})
		}
		return r.json()
	})

	return { profile }
}

The +error.svelte component at any level can then access these fields through page.error:

<!-- src/routes/+error.svelte -->
<script>
	import { page } from '$app/state'
</script>

<div class="error-page">
	<h1>{page.status}</h1>
	<p>{page.error?.message}</p>

	{#if page.error?.code}
		<code class="error-code">{page.error.code}</code>
	{/if}

	{#if page.error?.correlationId}
		<p class="correlation">
			Reference ID: <code>{page.error.correlationId}</code>
		</p>
	{/if}

	{#if page.error?.detail}
		<details>
			<summary>Technical details</summary>
			<p>{page.error.detail}</p>
		</details>
	{/if}
</div>

One subtle note: you also need to implement the handleError hook in src/hooks.server.ts if you want uncaught errors (those not explicitly thrown with error()) to conform to your App.Error shape. Without it, unexpected errors will have only { message: string } and none of your additional fields:

// src/hooks.server.ts

import type { HandleServerError } from '@sveltejs/kit'

export const handleError: HandleServerError = ({ error, event, status, message }) => {
	const correlationId = event.locals.requestId ?? 'unknown'

	// Log the full error server-side for diagnostics
	console.error(`[${correlationId}] Unhandled error:`, error)

	// Return only what is safe to expose to the client
	return {
		message: status === 500 ? 'An unexpected error occurred.' : message,
		code: 'INTERNAL_ERROR',
		correlationId
	}
}

The handleError hook is the adapter between your uncontrolled server errors and the typed App.Error shape you promised to deliver. Treat it as a required companion to any non-trivial App.Error definition.


App.Platform: Targeting Specific Deployment Environments

App.Platform is the most environment-specific of the five interfaces. It types the event.platform object, which SvelteKit adapters populate with platform-specific APIs and context when your application runs on a particular deployment target.

If you deploy to Node.js with adapter-node, event.platform is essentially undefined because Node.js itself provides all the standard APIs you need. But if you deploy to Cloudflare Workers or Cloudflare Pages, the platform object carries bindings to Cloudflare’s infrastructure: KV namespaces, R2 buckets, D1 databases, Durable Objects, environment variables, and the execution context for waitUntil.

Without App.Platform declared, accessing event.platform gives you App.Platform | undefined, and App.Platform is an empty interface, so every property access is a type error.

Here is a realistic Cloudflare Pages declaration:

// src/app.d.ts

declare global {
	namespace App {
		interface Locals {
			user: import('$lib/types').User | null
			requestId: string
		}

		interface Platform {
			env: {
				// KV namespace for session storage
				SESSIONS_KV: KVNamespace
				// R2 bucket for user file uploads
				UPLOADS_BUCKET: R2Bucket
				// D1 database binding
				DB: D1Database
				// Plain environment variables (secrets, config)
				AUTH_SECRET: string
				INTERNAL_API_URL: string
			}
			context: {
				waitUntil(promise: Promise<unknown>): void
			}
			caches: CacheStorage & { default: Cache }
			cf?: IncomingRequestCfProperties
		}
	}
}

export {}

Note that KVNamespace, R2Bucket, D1Database, CacheStorage, and IncomingRequestCfProperties are types that come from @cloudflare/workers-types. You install that package as a dev dependency and reference it here. The types do not need to be imported with an import statement because they are declared as globals by the workers-types package itself.

With this declared, your server-side code gains full type safety for Cloudflare APIs:

// src/hooks.server.ts

import type { Handle } from '@sveltejs/kit'
import { nanoid } from 'nanoid'

export const handle: Handle = async ({ event, resolve }) => {
	event.locals.requestId = nanoid()

	// TypeScript knows event.platform.env.SESSIONS_KV is a KVNamespace
	const sessionToken = event.cookies.get('session')

	if (sessionToken && event.platform) {
		const sessionJson = await event.platform.env.SESSIONS_KV.get(sessionToken)

		if (sessionJson) {
			const session = JSON.parse(sessionJson)
			event.locals.user = session.user
		} else {
			event.locals.user = null
		}
	} else {
		event.locals.user = null
	}

	const response = await resolve(event)

	// Use waitUntil to update session expiry without blocking the response
	if (sessionToken && event.platform && event.locals.user) {
		event.platform.context.waitUntil(
			event.platform.env.SESSIONS_KV.put(
				sessionToken,
				JSON.stringify({
					user: event.locals.user
				}),
				{ expirationTtl: 86400 }
			)
		)
	}

	return response
}

event.platform is always nullable because your code might run on a platform that does not populate it (for example, during vite dev). Always guard with if (event.platform) before accessing platform-specific APIs. The type system enforces this correctly: event.platform is typed as App.Platform | undefined.

If you target multiple platforms or want to support both Cloudflare and Node deployments from the same codebase, you can use a union approach:

// src/app.d.ts

interface CloudflarePlatform {
	env: {
		SESSIONS_KV: KVNamespace
		UPLOADS_BUCKET: R2Bucket
		AUTH_SECRET: string
	}
	context: { waitUntil(promise: Promise<unknown>): void }
}

interface VercelEdgePlatform {
	env: Record<string, string>
}

declare global {
	namespace App {
		interface Platform extends Partial<CloudflarePlatform>, Partial<VercelEdgePlatform> {}
	}
}

export {}

This pattern creates a platform interface where all platform-specific properties are optional. Your code then uses runtime checks ('env' in event.platform && 'SESSIONS_KV' in event.platform.env) in addition to the null guard to branch appropriately. It is a pragmatic approach when a clean platform separation is not possible.


App.PageState: Typed Shallow Routing

App.PageState is the newest of the five interfaces and the least familiar to most developers. It types the state property used with SvelteKit’s shallow routing API.

Shallow routing allows you to update the URL and push entries onto the browser history stack without triggering a full navigation and re-running load functions. The canonical use case is a detail view that overlays the current page — like clicking on a photo in a gallery to open a modal viewer — where the URL should update (so sharing works) but the underlying page should not reload.

You push shallow state using the pushState function from $app/navigation:

import { pushState } from '$app/navigation'

// Opens a modal showing the selected article without leaving the list
function openArticleModal(articleId: string) {
	pushState(`/articles/${articleId}`, {
		modalOpen: true,
		selectedArticleId: articleId
	})
}

The second argument to pushState is the state object. Without App.PageState, TypeScript accepts this as App.PageState & Record<string, unknown>, which is functionally untyped. With it declared, the state object is fully typed:

// src/app.d.ts

declare global {
	namespace App {
		interface PageState {
			modalOpen?: boolean
			selectedArticleId?: string
			selectedImageIndex?: number
			drawerOpen?: boolean
		}
	}
}

export {}

Reading the state back in a component uses page.state:

<!-- src/routes/articles/+page.svelte -->
<script>
	import { page } from '$app/state'
	import { pushState, replaceState } from '$app/navigation'
	import ArticleModal from '$lib/components/ArticleModal.svelte'
	import type { PageData } from './$types'

	let { data }: { data: PageData } = $props()

	function openModal(articleId: string) {
		pushState(`/articles/${articleId}`, {
			modalOpen: true,
			selectedArticleId: articleId
		})
	}

	function closeModal() {
		// replaceState to avoid adding a history entry for the close action
		replaceState('', {
			modalOpen: false,
			selectedArticleId: undefined
		})
	}
</script>

<ul>
	{#each data.articles as article}
		<li>
			<button onclick={() => openModal(article.id)}>
				{article.title}
			</button>
		</li>
	{/each}
</ul>

<!-- TypeScript knows page.state.modalOpen and page.state.selectedArticleId -->
{#if page.state.modalOpen && page.state.selectedArticleId}
	<ArticleModal articleId={page.state.selectedArticleId} onclose={closeModal} />
{/if}

All fields in App.PageState should be optional, because page state starts empty when the user first loads a URL directly. You can never guarantee what state exists in the history entry the user landed on.


Composing a Production app.d.ts

Everything covered so far comes together in a single file for a real application. Here is what a complete, production-grade app.d.ts looks like for a multi-tenant SaaS deployed to Cloudflare Pages:

// src/app.d.ts

declare global {
	namespace App {
		interface Locals {
			// Authenticated user, or null if the request is unauthenticated
			// This is the single source of truth for "who is making this request?" across the entire app.
			user: {
				// You should use imports from your shared types rather than inline shapes in real projects
				// user: import('$lib/types').User | null
				id: string
				email: string
				role: 'owner' | 'admin' | 'member' | 'viewer'
				displayName: string
				avatarUrl: string | null
			} | null

			// The resolved tenant from subdomain/path/header routing
			tenant: {
				// You should use imports from your shared types rather than inline shapes in real projects
				// tenant: import('$lib/types').Tenant | null
				id: string
				slug: string
				name: string
				plan: 'free' | 'pro' | 'enterprise'
				features: Record<string, boolean>
			} | null

			// Unique ID for this request, used in logs and error correlation
			requestId: string

			// Performance timing helper — populated by the handle hook
			requestStartTime: number
		}

		interface PageData {
			// Always populated by the root +layout.server.ts
			// Null when unauthenticated (public pages)
			currentUser: App.Locals['user']
			currentTenant: App.Locals['tenant']
		}

		interface Error {
			message: string
			code: string
			correlationId?: string
			detail?: string
			fieldErrors?: Record<string, string[]>
		}

		interface Platform {
			env: {
				// Session storage
				SESSIONS_KV: KVNamespace
				// File storage
				ASSETS_BUCKET: R2Bucket
				// Primary database
				DB: D1Database
				// Secrets
				AUTH_SECRET: string
				ENCRYPTION_KEY: string
				INTERNAL_API_KEY: string
			}
			context: {
				waitUntil(promise: Promise<unknown>): void
				passThroughOnException(): void
			}
			cf?: IncomingRequestCfProperties
		}

		interface PageState {
			// Overlay/modal routing state
			modalOpen?: boolean
			modalType?: 'create-project' | 'invite-member' | 'confirm-delete' | 'view-asset'
			modalEntityId?: string

			// Drawer / panel state for split-view layouts
			detailPanelOpen?: boolean
			selectedItemId?: string
		}
	}
}

export {}

This file is roughly 60 lines. It describes the runtime contract of an entire SaaS application: who can be authenticated, what tenant context flows through every request, what platform APIs are available, how errors are shaped, what page-level overlay state exists. Any developer joining the project can read this file and understand the fundamental data model of the application in under five minutes.


The Data Flow, End to End

It helps to see how the types declared in app.d.ts connect across the full request lifecycle. The following diagram traces a single authenticated request from HTTP arrival to component render:

Loading diagram...

The types flow because App.Locals is declared once and TypeScript automatically threads it through the type of event in every hook, load function, and server action across your codebase.


Common Mistakes and Anti-Patterns

Forgetting the export {} at the Bottom

// Avoid — missing export {}
declare global {
	namespace App {
		interface Locals {
			user: User | null
		}
	}
}
// Preferred — always include export {}
declare global {
	namespace App {
		interface Locals {
			user: User | null
		}
	}
}

export {}

Without export {}, TypeScript treats the file as a global script rather than a module. declare global inside a script context does not work the way you expect, and augmentation silently fails. Your event.locals.user will be typed as any or generate errors, and the cause will be difficult to trace. Always include export {}.

Using Inline Types Instead of Importing Shared Definitions

When you declare User type information directly in app.d.ts, you create a maintenance hazard. If your User type changes (for example, you add a displayName field), you have to remember to update it in two places: the source of truth (e.g., $lib/types/user.ts) and the inline declaration in app.d.ts. This duplication is error-prone and can lead to type mismatches that TypeScript cannot catch.

// Avoid — duplicating type definitions inline in app.d.ts
declare global {
	namespace App {
		interface Locals {
			user: {
				id: string
				email: string
				role: 'admin' | 'editor'
			} | null
		}
	}
}
// Preferred — import from a shared type definition
declare global {
	namespace App {
		interface Locals {
			user: import('$lib/types/user').User | null
		}
	}
}

Populating Locals Fields Without Declaring Them

// Avoid — setting a field TypeScript has not been told about
export const handle: Handle = async ({ event, resolve }) => {
	;(event.locals as any).featureFlags = await loadFeatureFlags()
	return resolve(event)
}
// Preferred — declare it, then set it
// In app.d.ts:
interface Locals {
	featureFlags: Record<string, boolean>
}

// In hooks.server.ts:
export const handle: Handle = async ({ event, resolve }) => {
	event.locals.featureFlags = await loadFeatureFlags()
	return resolve(event)
}

Using as any to bypass locals typing is a common short-cut that defeats the entire purpose of App.Locals. The TypeScript error telling you a property does not exist on event.locals is a signal that you need to update app.d.ts, not a reason to cast to any.

Putting Route-Specific Data into App.PageData

// Avoid — specific route data in App.PageData
interface PageData {
	currentUser: User | null
	articleList: Article[] // Only exists on /articles
	dashboardStats: Stats // Only exists on /dashboard
}
// Preferred — only genuinely universal data in App.PageData
interface PageData {
	currentUser: User | null
	siteConfig: SiteConfig
}

App.PageData promises that these fields exist on every page. If articleList only comes from the /articles route loader, it will be undefined everywhere else, creating a false type contract that will cause runtime errors. Keep App.PageData lean and genuinely universal.


Performance and Scaling Considerations

app.d.ts is a compile-time construct. It has zero runtime cost. TypeScript erases all type information during compilation, so the interfaces you declare here produce no JavaScript output whatsoever.

The performance considerations are therefore about design rather than execution. A Locals interface with many deeply nested fields encourages a pattern where the handle hook does a great deal of work on every request, even for routes that need none of that data. A request to /favicon.ico should not trigger a full session validation and tenant resolution sequence.

Structuring your handle hook to be conditional based on the request path is a practical response to this, and app.d.ts supports it well through optional fields:

// src/app.d.ts

interface Locals {
	requestId: string // Always populated
	user: import('$lib/types').User | null // Populated for non-static routes
	tenant: import('$lib/types').Tenant | null // Populated for tenant routes only
}

Making user and tenant nullable (rather than required non-null types) signals in the type system that not every request populates them. Load functions that require authentication check for null and redirect or error accordingly. This is accurate modeling of your application’s behavior.

For large teams working on the same codebase, app.d.ts becomes a coordination point. Changes to App.Locals affect every load function and hook across the entire project. Removing a field or changing its type is a refactor that TypeScript will guide you through with compile errors at every usage site, which is exactly the right tool for the job. Treat app.d.ts changes with the same care you would give to a shared API contract.


When NOT to Rely Solely on app.d.ts

app.d.ts handles typing at the SvelteKit framework boundary, but it does not replace a well-structured type system for your application’s own domain model. Do not put your entire domain type system into app.d.ts. The interfaces here should be thin references to types that live in $lib/types, not the source of truth for those types.

Similarly, app.d.ts does not provide runtime validation. If your session validation hook receives a malformed JSON response from an auth API and you assign it directly to event.locals.user, TypeScript will not detect the mismatch at runtime. For data crossing a network boundary, use a schema validation library (such as Zod or Valibot) to validate before you assign to locals. The type declaration in app.d.ts then documents the post-validation contract, not the pre-validation hope.

If you are building a library or a SvelteKit package intended for use inside other projects, you cannot augment the App namespace yourself; that privilege belongs to the consuming application. Library-specific types must use other mechanisms (typed exports, generic wrappers) rather than app.d.ts augmentation.


Conclusion

app.d.ts is the smallest file in a SvelteKit project and one of the most consequential. It is the file where you describe, in TypeScript’s language, the runtime contract that your application upholds. Declare App.Locals and your entire request pipeline gains coherent, framework-integrated type safety. Declare App.Error and your error handling becomes consistent and trustworthy from server hook to error component. Declare App.Platform and your deployment target’s APIs become first-class TypeScript citizens.

The investment required is minimal: a handful of interface declarations that you write once and update as your application evolves. The return is substantial: a codebase where TypeScript understands the full flow of data from HTTP request to rendered component, where type errors catch contract violations before they reach production, and where new contributors can understand the application’s runtime data model by reading a single file.


Key Takeaways

app.d.ts uses TypeScript module augmentation to inject types into SvelteKit’s framework interfaces, making it the single source of truth for your application’s runtime type contract.

Key takeaways:

  • App.Locals types event.locals across all hooks, load functions, and server actions without any imports or manual casting
  • App.Error defines the shape of all error objects; pair it with handleError in hooks.server.ts to ensure uncaught errors conform to the same shape
  • App.Platform makes deployment target APIs fully typed; always guard with if (event.platform) since it can be undefined in development
  • App.PageData should only contain fields that the root layout genuinely populates for every page; route-specific data belongs in route-specific types
  • App.PageState types the state passed to pushState and replaceState for shallow routing; all fields must be optional
  • Always include export {} at the end of the file or the augmentation silently fails
  • Use import() type expressions to reference shared type definitions rather than duplicating them inline

Further Reading