Have you ever used a JavaScript object like a dictionary to store key-value pairs? Most of us do it all the time, it’s one of the most common patterns in JavaScript. But there’s a hidden problem that can cause unexpected bugs, especially in web applications where data comes from users, URLs, or external APIs.
In this article, you’ll learn about a simple technique called Object.create(null) that creates truly empty objects, why it matters for your Svelte 5 and SvelteKit projects, and see 8 real-world examples where this pattern can save you from subtle bugs.
What’s the Problem with Regular Objects?
When you create an object using the familiar curly braces {}, you might think you’re creating an empty container. But you’re not! Every object in JavaScript secretly inherits properties from something called Object.prototype.
Think of it like this: when you buy an “empty” folder, it might still have a label, a pocket, or other features built into it. Similarly, an “empty” JavaScript object comes with built-in properties.
Let’s see this in action:
// You might think this creates an empty object...
const myObject = {}
// But it actually has hidden properties!
console.log(myObject.toString) // [Function: toString]
console.log(myObject.constructor) // [Function: Object]
console.log(myObject.hasOwnProperty) // [Function: hasOwnProperty]
console.log('__proto__' in myObject) // true
// These properties exist even though we never added them! Why Does This Matter?
In most cases, these inherited properties don’t cause problems. But imagine you’re building a blog where users can create posts with any URL slug they want. What happens if someone creates a post with the slug “constructor”?
// A simple cache for blog posts
const postCache = {}
// Store some posts
postCache['hello-world'] = { title: 'Hello World', content: '...' }
postCache['my-first-post'] = { title: 'My First Post', content: '...' }
// Now someone creates a post with the slug "constructor"
console.log(postCache['constructor'])
// Output: [Function: Object] - NOT undefined!
// The cache thinks there's already a post here because
// "constructor" is an inherited property! This is called prototype pollution, and it can lead to:
- Bugs: Your code behaves unexpectedly when certain keys are used
- Security issues: Attackers can exploit these inherited properties
- Hard-to-find errors: These bugs only appear with specific key names
The Solution: Object.create(null)
JavaScript gives us a way to create a truly empty object with no inherited properties at all. The syntax is Object.create(null):
// This creates a TRULY empty object
const cleanObject = Object.create(null)
// No inherited properties!
console.log(cleanObject.toString) // undefined
console.log(cleanObject.constructor) // undefined
console.log(cleanObject.hasOwnProperty) // undefined
console.log('__proto__' in cleanObject) // false
// Now we can safely use any key
cleanObject['constructor'] = { title: 'Constructor Pattern', content: '...' }
console.log(cleanObject['constructor']) // { title: 'Constructor Pattern', content: '...' }
// It works correctly! How Does It Work?
The Object.create() method creates a new object and lets you specify what its prototype should be. When you pass null, you’re saying “this object should have no prototype at all” - making it a completely blank slate.
// Regular object: has Object.prototype as its prototype
const regular = {}
console.log(Object.getPrototypeOf(regular)) // [Object: null prototype] {}
// Null-prototype object: has no prototype
const empty = Object.create(null)
console.log(Object.getPrototypeOf(empty)) // null When Should You Use This Pattern?
Here’s a simple rule of thumb:
Use Object.create(null) when:
- Keys come from user input (form fields, URL parameters)
- Keys come from external sources (APIs, databases, CMS)
- Keys come from URLs (route parameters, query strings)
- You’re building a cache, lookup table, or dictionary
- You don’t know in advance what keys will be used
Use regular {} when:
- You control all the keys (they’re hardcoded in your code)
- You’re creating a structured object with known properties
- You need methods like
.toString()or.hasOwnProperty()
8 Real-World Examples for Svelte 5 / SvelteKit
Now let’s see how this pattern applies specifically to Svelte 5 and SvelteKit applications. Each example shows a common scenario where Object.create(null) prevents potential bugs.
1. Safe Route Parameter Caching in SvelteKit
The Scenario: You’re building a blog with SvelteKit, and you want to cache database queries to improve performance. The cache keys are the URL slugs from your routes (like /blog/my-post).
The Problem: URL slugs come from users - someone could create a post with the slug “constructor” or ”proto”, which would conflict with inherited object properties.
The Solution:
// src/lib/server/cache.ts
/**
* Creates a type-safe cache that's immune to prototype pollution.
*
* Why Object.create(null)?
* When we cache blog posts by their URL slug, the slug comes from the URL.
* A malicious user could request /blog/constructor or /blog/__proto__
* and break our cache if we used a regular object.
*/
function createCache<T>() {
// Object.create(null) ensures no inherited properties
const store: Record<string, T> = Object.create(null)
return {
/**
* Get an item from the cache
* Returns undefined if the key doesn't exist (never returns inherited properties)
*/
get: (key: string): T | undefined => store[key],
/**
* Store an item in the cache
*/
set: (key: string, value: T): void => {
store[key] = value
},
/**
* Check if a key exists in the cache
* The `in` operator is safe here because there's no prototype chain
*/
has: (key: string): boolean => key in store,
/**
* Remove an item from the cache
*/
delete: (key: string): void => {
delete store[key]
},
/**
* Clear all items from the cache
*/
clear: (): void => {
for (const key of Object.keys(store)) {
delete store[key]
}
}
}
}
// Create a cache for blog posts
export const postCache = createCache<{
title: string
content: string
publishedAt: Date
}>() // src/routes/blog/[slug]/+page.server.ts
import { postCache } from '$lib/server/cache'
import * as db from '$lib/server/database'
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ params }) => {
// params.slug comes from the URL
// Examples: "hello-world", "my-post", or even "constructor"!
// Check if we have this post cached
if (postCache.has(params.slug)) {
console.log(`Cache hit for: ${params.slug}`)
return { post: postCache.get(params.slug) }
}
// Not in cache, fetch from database
console.log(`Cache miss for: ${params.slug}`)
const post = await db.getPost(params.slug)
// Store in cache for next time
if (post) {
postCache.set(params.slug, post)
}
return { post }
} What could go wrong without Object.create(null)?
// If we used a regular object:
const unsafeCache = {}
// A request to /blog/constructor would NOT return undefined
console.log(unsafeCache['constructor']) // [Function: Object]
// Our cache.has() check would return true even for non-existent posts!
console.log('constructor' in unsafeCache) // true - WRONG! 2. Form Field Error Tracking with Svelte 5 Runes
The Scenario: You’re building a registration form with real-time validation. As users type, you want to show error messages next to each field.
The Problem: Form field names come from your HTML, and sometimes from dynamic sources like a form builder. If a field is named “constructor” or “toString”, using a regular object to track errors would cause problems.
The Solution:
<!-- src/routes/register/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms'
import type { ActionData } from './$types'
// Get form response from the server (if any)
let { form }: { form: ActionData } = $props()
/**
* Store validation errors for each field.
*
* Why Object.create(null)?
* Form field names could theoretically be anything, including
* "constructor", "toString", or "__proto__". Using Object.create(null)
* ensures that looking up any field name works correctly.
*
* The $state() rune makes this object reactive, so the UI updates
* automatically when errors change.
*/
let fieldErrors: Record<string, string> = $state(Object.create(null))
/**
* Derived state that tells us if the form has any errors.
* This automatically updates whenever fieldErrors changes.
*/
let hasErrors = $derived(Object.keys(fieldErrors).length > 0)
/**
* Count of total errors (useful for showing "3 errors remaining")
*/
let errorCount = $derived(Object.keys(fieldErrors).length)
/**
* Validate a single field and update the errors object.
* @param name - The field name (could be any string)
* @param value - The current field value
*/
function validateField(name: string, value: string) {
// Simple validation rules
if (!value.trim()) {
// Field is empty - add an error
fieldErrors[name] = `${name} is required`
} else if (name === 'email' && !value.includes('@')) {
// Email validation
fieldErrors[name] = 'Please enter a valid email address'
} else if (name === 'password' && value.length < 8) {
// Password length check
fieldErrors[name] = 'Password must be at least 8 characters'
} else {
// Field is valid - remove any existing error
delete fieldErrors[name]
}
}
/**
* Clear all errors (useful for form reset)
*/
function clearErrors() {
// Create a fresh empty object
fieldErrors = Object.create(null)
}
</script>
<h1>Create an Account</h1>
{#if errorCount > 0}
<p class="error-summary">
Please fix {errorCount} error{errorCount > 1 ? 's' : ''} below
</p>
{/if}
<form method="POST" use:enhance>
<!-- Username field -->
<label>
<span>Username</span>
<input
name="username"
type="text"
oninput={(e) => validateField('username', e.currentTarget.value)}
class:invalid={fieldErrors['username']}
/>
{#if fieldErrors['username']}
<span class="error">{fieldErrors['username']}</span>
{/if}
</label>
<!-- Email field -->
<label>
<span>Email</span>
<input
name="email"
type="email"
oninput={(e) => validateField('email', e.currentTarget.value)}
class:invalid={fieldErrors['email']}
/>
{#if fieldErrors['email']}
<span class="error">{fieldErrors['email']}</span>
{/if}
</label>
<!-- Password field -->
<label>
<span>Password</span>
<input
name="password"
type="password"
oninput={(e) => validateField('password', e.currentTarget.value)}
class:invalid={fieldErrors['password']}
/>
{#if fieldErrors['password']}
<span class="error">{fieldErrors['password']}</span>
{/if}
</label>
<!--
Example of a field with an unusual name.
In a real app, this might come from a form builder or CMS.
-->
<label>
<span>Company Constructor (legal entity name)</span>
<input
name="constructor"
type="text"
oninput={(e) => validateField('constructor', e.currentTarget.value)}
class:invalid={fieldErrors['constructor']}
/>
{#if fieldErrors['constructor']}
<span class="error">{fieldErrors['constructor']}</span>
{/if}
</label>
<div class="actions">
<button type="button" onclick={clearErrors}>Clear Errors</button>
<button type="submit" disabled={hasErrors}>Create Account</button>
</div>
</form>
<style>
.error {
color: red;
font-size: 0.875rem;
}
.error-summary {
background: #fee;
border: 1px solid #fcc;
padding: 1rem;
border-radius: 4px;
}
.invalid {
border-color: red;
}
</style> 3. Dynamic Component Registry for Page Builders
The Scenario: You’re building a page builder or CMS where users can add different types of content blocks (text, images, videos, etc.). Component names come from the database or a configuration file.
The Problem: Component type names are strings that could be anything - including JavaScript reserved words or inherited property names.
The Solution:
// src/lib/components/registry.svelte.ts
import type { Component } from 'svelte'
/**
* A registry for dynamically loading Svelte components by name.
*
* Why Object.create(null)?
* Component names come from the CMS or database. A content editor might
* name a component "Constructor" (for a page about construction) or
* "Prototype" (for a prototyping tool section). With a regular object,
* these names would clash with inherited properties.
*/
class ComponentRegistry {
// Private storage with no prototype
private components: Record<string, Component> = Object.create(null)
/**
* Register a component with a given name.
* @param name - Unique identifier for the component (any string is safe)
* @param component - The Svelte component to register
*/
register(name: string, component: Component): void {
// Check if already registered (safe because no prototype)
if (name in this.components) {
console.warn(`Component "${name}" is already registered. Skipping.`)
return
}
this.components[name] = component
console.log(`Registered component: ${name}`)
}
/**
* Register multiple components at once.
* @param components - Object mapping names to components
*/
registerMany(components: Record<string, Component>): void {
for (const [name, component] of Object.entries(components)) {
this.register(name, component)
}
}
/**
* Get a component by name.
* @param name - The component name to look up
* @returns The component, or undefined if not found
*/
get(name: string): Component | undefined {
return this.components[name]
}
/**
* Check if a component is registered.
* @param name - The component name to check
* @returns true if the component exists
*/
has(name: string): boolean {
// The `in` operator is safe here because there's no prototype chain
// With a regular object, `'constructor' in obj` would return true!
return name in this.components
}
/**
* Get a list of all registered component names.
* @returns Array of component names
*/
list(): string[] {
return Object.keys(this.components)
}
/**
* Remove a component from the registry.
* @param name - The component name to remove
*/
unregister(name: string): void {
delete this.components[name]
}
}
// Export a singleton instance
export const registry = new ComponentRegistry() <!-- src/routes/page-builder/+page.svelte -->
<script lang="ts">
import { registry } from '$lib/components/registry.svelte'
// Import your block components
import TextBlock from '$lib/components/blocks/TextBlock.svelte'
import ImageBlock from '$lib/components/blocks/ImageBlock.svelte'
import VideoBlock from '$lib/components/blocks/VideoBlock.svelte'
import ConstructorBlock from '$lib/components/blocks/ConstructorBlock.svelte'
// Register all available components
registry.registerMany({
text: TextBlock,
image: ImageBlock,
video: VideoBlock,
constructor: ConstructorBlock, // A block about construction - works fine!
prototype: TextBlock // A block about prototyping - also safe!
})
// Page data from the server (loaded from CMS)
let { data } = $props()
// data.blocks might look like:
// [
// { type: 'text', props: { content: 'Hello world' } },
// { type: 'image', props: { src: '/photo.jpg', alt: 'A photo' } },
// { type: 'constructor', props: { content: 'Building tips...' } },
// ]
</script>
<main class="page-builder">
<h1>{data.pageTitle}</h1>
{#each data.blocks as block, index}
{#if registry.has(block.type)}
<!-- Dynamically render the correct component -->
{@const BlockComponent = registry.get(block.type)}
<section class="block" data-type={block.type}>
<BlockComponent {...block.props} />
</section>
{:else}
<!-- Handle unknown component types gracefully -->
<section class="block block--unknown">
<p>⚠️ Unknown block type: "{block.type}"</p>
<p>Available types: {registry.list().join(', ')}</p>
</section>
{/if}
{/each}
</main>
<style>
.block {
padding: 1rem;
margin: 1rem 0;
border: 1px solid #eee;
border-radius: 8px;
}
.block--unknown {
background: #fff3cd;
border-color: #ffc107;
}
</style> 4. Server-Side Permission Checking in Hooks
The Scenario: You’re implementing role-based access control (RBAC) in your SvelteKit app. Different routes require different permissions, and you want to check permissions in a server hook before the page loads.
The Problem: Route paths are strings, and some valid routes might have names that conflict with object properties (like /api/constructor for a construction API).
The Solution:
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit'
/**
* Maps route paths to required permissions.
*
* Why Object.create(null)?
* Route paths are strings that we use as object keys. While most paths
* are normal (like '/admin' or '/dashboard'), you might have routes like:
* - /api/constructor (for a construction company API)
* - /docs/prototype (for prototyping documentation)
* - /legal/__proto__ (unlikely, but possible in user-generated routes)
*
* Using Object.create(null) ensures all paths work correctly.
*/
const routePermissions: Record<string, string[]> = Object.create(null)
// Define which routes require which permissions
// Public routes (no permissions needed) are simply not listed here
routePermissions['/admin'] = ['admin']
routePermissions['/admin/users'] = ['admin', 'user-manager']
routePermissions['/admin/settings'] = ['admin']
routePermissions['/dashboard'] = ['user', 'admin']
routePermissions['/dashboard/analytics'] = ['analyst', 'admin']
routePermissions['/api/constructor'] = ['developer'] // Works correctly!
routePermissions['/api/prototype'] = ['developer'] // Also works!
/**
* Check if a user has at least one of the required permissions.
* @param userPermissions - Array of permissions the user has
* @param requiredPermissions - Array of permissions needed for the route
* @returns true if the user has at least one required permission
*/
function hasPermission(userPermissions: string[], requiredPermissions: string[]): boolean {
return requiredPermissions.some((permission) => userPermissions.includes(permission))
}
/**
* The main request handler hook.
* This runs for every request before the route is resolved.
*/
export const handle: Handle = async ({ event, resolve }) => {
const { pathname } = event.url
// Look up permissions for this route
// With Object.create(null), this is safe for ANY pathname
const requiredPermissions = routePermissions[pathname]
// If no permissions required, allow access
if (!requiredPermissions) {
return resolve(event)
}
// Get the current user from locals (set by auth middleware)
const user = event.locals.user
// No user? Redirect to login
if (!user) {
return new Response(null, {
status: 302,
headers: { Location: `/login?redirectTo=${encodeURIComponent(pathname)}` }
})
}
// Check if user has required permissions
if (!hasPermission(user.permissions, requiredPermissions)) {
// User doesn't have permission
return new Response(
JSON.stringify({
error: 'Forbidden',
message: 'You do not have permission to access this resource',
required: requiredPermissions
}),
{
status: 403,
headers: { 'Content-Type': 'application/json' }
}
)
}
// User has permission, continue to the route
return resolve(event)
} 5. Reactive Feature Flags with createContext
The Scenario: You’re implementing feature flags to gradually roll out new features. Flag names come from a feature flag service or configuration, and you want them to be reactive so the UI updates when flags change.
The Problem: Feature flag names are arbitrary strings defined by your team or a third-party service. Names like “constructor-redesign” or “prototype-mode” could cause issues with regular objects.
The Solution:
// src/lib/features/context.svelte.ts
import { createContext } from 'svelte'
/**
* Type definition for our feature flags context.
*/
interface FeatureFlags {
/** Check if a specific flag is enabled */
isEnabled: (flag: string) => boolean
/** Enable or disable a flag at runtime */
setFlag: (flag: string, enabled: boolean) => void
/** Get all flag names */
listFlags: () => string[]
}
/**
* Create a type-safe context for feature flags.
*
* createContext() returns a [get, set] tuple:
* - get() retrieves the context value (throws if not set)
* - set() sets the context value
*
* This is available in Svelte 5.40.0 and later.
*/
const [getFeatureFlags, setFeatureFlags] = createContext<FeatureFlags>()
// Export the getter for use in child components
export { getFeatureFlags }
/**
* Initialize the feature flags context.
* Call this in your root layout to make flags available throughout the app.
*
* @param initialFlags - Initial flag values from server/config
* @returns The feature flags context
*
* Why Object.create(null)?
* Feature flag names come from configuration or external services.
* Real examples that could cause issues with regular objects:
* - "constructor-v2" (new constructor pattern)
* - "prototype-testing" (A/B test for prototype feature)
* - "toString-format" (different string formatting)
*
* Using Object.create(null) ensures ALL flag names work correctly.
*/
export function initFeatureFlags(initialFlags: Record<string, boolean> = {}) {
// Create reactive state with a null prototype
// This combines Svelte 5's reactivity with prototype safety
let flags: Record<string, boolean> = $state(Object.create(null))
// Safely copy initial flags
// We iterate and assign instead of using Object.assign to ensure
// we don't accidentally copy prototype properties
for (const [flagName, isEnabled] of Object.entries(initialFlags)) {
flags[flagName] = isEnabled
}
// Create the context object
const context: FeatureFlags = {
/**
* Check if a feature flag is enabled.
* Returns false for unknown flags (fail-safe default).
*/
isEnabled: (flag: string): boolean => {
// Safe lookup - works for ANY flag name
return flags[flag] === true
},
/**
* Enable or disable a flag at runtime.
* Useful for testing or gradual rollouts.
*/
setFlag: (flag: string, enabled: boolean): void => {
flags[flag] = enabled
// Reactivity automatically updates all components using this flag!
},
/**
* Get a list of all defined flags.
*/
listFlags: (): string[] => {
return Object.keys(flags)
}
}
// Set the context and return it
return setFeatureFlags(context)
} <!-- src/routes/+layout.svelte -->
<script lang="ts">
import { initFeatureFlags } from '$lib/features/context.svelte'
let { data, children } = $props()
// Initialize feature flags from server data
// These might come from a feature flag service like LaunchDarkly, Split, etc.
initFeatureFlags(data.featureFlags)
// data.featureFlags might look like:
// {
// 'new-dashboard': true,
// 'dark-mode': false,
// 'constructor-pattern': true, // Safe!
// 'prototype-mode': false, // Also safe!
// }
</script>
<!-- Render the rest of the app -->
{@render children()} <!-- src/routes/dashboard/+page.svelte -->
<script lang="ts">
import { getFeatureFlags } from '$lib/features/context.svelte'
// Get the feature flags context
// This will throw an error if initFeatureFlags wasn't called in a parent,
// helping you catch configuration mistakes early
const features = getFeatureFlags()
</script>
<h1>Dashboard</h1>
<!-- Conditionally render based on feature flags -->
{#if features.isEnabled('new-dashboard')}
<p>🎉 You're seeing the new dashboard design!</p>
<NewDashboard />
{:else}
<LegacyDashboard />
{/if}
<!-- Even flags with "dangerous" names work correctly -->
{#if features.isEnabled('constructor-pattern')}
<section class="constructor-features">
<h2>Constructor Pattern Tools</h2>
<!-- ... -->
</section>
{/if}
<!-- Toggle flags for testing -->
<div class="debug-panel">
<h3>Feature Flags (Debug)</h3>
<ul>
{#each features.listFlags() as flag}
<li>
<label>
<input
type="checkbox"
checked={features.isEnabled(flag)}
onchange={() => features.setFlag(flag, !features.isEnabled(flag))}
/>
{flag}
</label>
</li>
{/each}
</ul>
</div> 6. i18n Translation Keys
The Scenario: You’re building a multi-language website. Translation keys are defined in JSON files or loaded from a translation management system.
The Problem: Translation keys are arbitrary strings like “button.submit” or “error.constructor” (for error messages about constructors). These strings are used as object keys to look up translations.
The Solution:
// src/lib/i18n/translations.svelte.ts
import { createContext } from 'svelte'
/**
* Type for our translations object.
* Keys are translation keys, values are the translated strings.
*/
type Translations = Record<string, string>
/**
* Type for our translation store context.
*/
type TranslationStore = {
/** Translate a key, with optional fallback */
t: (key: string, fallback?: string) => string
/** Current locale code (e.g., 'en', 'de', 'fr') */
locale: string
/** Change the current locale */
setLocale: (locale: string) => Promise<void>
}
// Create type-safe context
const [getI18n, setI18n] = createContext<TranslationStore>()
export { getI18n }
/**
* Initialize the internationalization system.
*
* @param initialLocale - The starting locale (e.g., 'en')
* @param initialTranslations - Translation key-value pairs
*
* Why Object.create(null)?
* Translation keys come from JSON files created by translators.
* Real examples that would break with regular objects:
* - "constructor.title" → "Constructor Pattern"
* - "error.hasOwnProperty" → "Property Error"
* - "prototype.description" → "About Prototypes"
*
* With Object.create(null), ANY translation key works safely.
*/
export function initI18n(initialLocale: string, initialTranslations: Translations) {
// Create reactive state for translations (null prototype = safe)
let translations: Translations = $state(Object.create(null))
// Reactive state for current locale
let locale = $state(initialLocale)
// Safely copy initial translations
for (const [key, value] of Object.entries(initialTranslations)) {
translations[key] = value
}
const store: TranslationStore = {
/**
* Get a translation by key.
*
* @param key - The translation key (e.g., "button.submit")
* @param fallback - Optional fallback if key not found
* @returns The translation, fallback, or the key itself
*
* The lookup is safe for ANY key because we use Object.create(null)
*/
t: (key: string, fallback?: string): string => {
// Look up the translation
const translation = translations[key]
// Return translation if found
if (translation !== undefined) {
return translation
}
// Return fallback if provided
if (fallback !== undefined) {
return fallback
}
// Return the key itself (helps identify missing translations)
console.warn(`Missing translation: ${key}`)
return key
},
/** Get the current locale */
get locale() {
return locale
},
/**
* Change the locale and load new translations.
*/
setLocale: async (newLocale: string): Promise<void> => {
// Don't reload if same locale
if (newLocale === locale) return
try {
// Fetch translations for the new locale
const response = await fetch(`/api/translations/${newLocale}`)
if (!response.ok) {
throw new Error(`Failed to load translations for ${newLocale}`)
}
const newTranslations = await response.json()
// Reset translations with a fresh null-prototype object
translations = Object.create(null)
// Copy new translations safely
for (const [key, value] of Object.entries(newTranslations)) {
translations[key] = value as string
}
// Update locale
locale = newLocale
console.log(`Locale changed to: ${newLocale}`)
} catch (error) {
console.error(`Failed to change locale to ${newLocale}:`, error)
throw error
}
}
}
return setI18n(store)
} <!-- src/routes/+page.svelte -->
<script lang="ts">
import { getI18n } from '$lib/i18n/translations.svelte'
const { t, locale, setLocale } = getI18n()
// Available languages
const languages = [
{ code: 'en', name: 'English' },
{ code: 'de', name: 'Deutsch' },
{ code: 'fr', name: 'Français' }
]
</script>
<header>
<!-- Language selector -->
<select value={locale} onchange={(e) => setLocale(e.currentTarget.value)}>
{#each languages as lang}
<option value={lang.code}>{lang.name}</option>
{/each}
</select>
</header>
<main>
<!-- Normal translations -->
<h1>{t('welcome.title', 'Welcome!')}</h1>
<p>{t('welcome.description', 'Thanks for visiting our site.')}</p>
<!--
Translation keys that would break with regular objects work fine!
Imagine your translations JSON has:
{
"constructor.title": "Constructor Pattern",
"constructor.description": "Learn about the constructor pattern",
"prototype.learn": "Understanding Prototypes"
}
-->
<section>
<h2>{t('constructor.title')}</h2>
<p>{t('constructor.description')}</p>
</section>
<button>{t('button.submit', 'Submit')}</button>
</main> 7. URL Query Parameter State Management
The Scenario: You’re building a product listing page with filters for category, price range, sorting, etc. You want the filter state to be stored in the URL so users can share filtered views.
The Problem: URL query parameters are completely user-controlled. Someone could manually add ?constructor=value or ?__proto__=hack to the URL.
The Solution:
<!-- src/lib/components/FilterPanel.svelte -->
<script lang="ts">
import { page } from '$app/state'
import { goto } from '$app/navigation'
/**
* Parse URL search parameters into a safe object.
*
* Why Object.create(null)?
* URL parameters come directly from the browser's address bar.
* Users can type ANYTHING into the URL, including:
* - ?constructor=value
* - ?__proto__=malicious
* - ?hasOwnProperty=oops
*
* Using Object.create(null) ensures we handle ALL parameter names safely.
*
* @returns A safe object containing all URL parameters
*/
function parseFilters(): Record<string, string> {
// Create a truly empty object
const filters: Record<string, string> = Object.create(null)
// Iterate through all URL parameters
// page.url.searchParams is a URLSearchParams object
for (const [key, value] of page.url.searchParams) {
filters[key] = value
}
return filters
}
/**
* Reactive derived state that automatically updates when the URL changes.
* Svelte's $derived tracks page.url.searchParams and re-runs parseFilters()
* whenever the URL changes.
*/
let filters = $derived(parseFilters())
/**
* Computed property: check if any filters are active
*/
let hasActiveFilters = $derived(Object.keys(filters).length > 0)
/**
* Computed property: count of active filters
*/
let filterCount = $derived(Object.keys(filters).length)
/**
* Update a single filter in the URL.
*
* @param name - The parameter name
* @param value - The new value (empty string to remove)
*/
async function setFilter(name: string, value: string) {
// Create a copy of the current URL
const url = new URL(page.url)
if (value) {
// Set the parameter
url.searchParams.set(name, value)
} else {
// Remove the parameter if value is empty
url.searchParams.delete(name)
}
// Navigate to the new URL without full page reload
await goto(url, {
replaceState: true, // Don't add to browser history
noScroll: true // Don't scroll to top
})
}
/**
* Clear all filters from the URL.
*/
async function clearFilters() {
await goto(page.url.pathname, {
replaceState: true
})
}
/**
* Remove a specific filter.
*/
async function removeFilter(name: string) {
await setFilter(name, '')
}
</script>
<div class="filter-panel">
<h3>
Filters
{#if filterCount > 0}
<span class="badge">{filterCount}</span>
{/if}
</h3>
<!-- Search input -->
<div class="filter-group">
<label for="search">Search</label>
<input
id="search"
type="text"
placeholder="Search products..."
value={filters['q'] ?? ''}
oninput={(e) => setFilter('q', e.currentTarget.value)}
/>
</div>
<!-- Category filter -->
<div class="filter-group">
<label for="category">Category</label>
<select
id="category"
value={filters['category'] ?? ''}
onchange={(e) => setFilter('category', e.currentTarget.value)}
>
<option value="">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
<option value="books">Books</option>
<option value="constructor">Construction Tools</option>
<!-- Safe! -->
</select>
</div>
<!-- Sort order -->
<div class="filter-group">
<label for="sort">Sort By</label>
<select
id="sort"
value={filters['sort'] ?? 'date'}
onchange={(e) => setFilter('sort', e.currentTarget.value)}
>
<option value="date">Newest First</option>
<option value="price-asc">Price: Low to High</option>
<option value="price-desc">Price: High to Low</option>
<option value="name">Name</option>
</select>
</div>
<!-- Active filters display -->
{#if hasActiveFilters}
<div class="active-filters">
<h4>Active Filters:</h4>
<div class="filter-tags">
{#each Object.entries(filters) as [key, value]}
<span class="filter-tag">
<strong>{key}:</strong>
{value}
<button onclick={() => removeFilter(key)} aria-label={`Remove ${key} filter`}>
×
</button>
</span>
{/each}
</div>
<button class="clear-all" onclick={clearFilters}> Clear All Filters </button>
</div>
{/if}
</div>
<style>
.filter-panel {
padding: 1rem;
background: #f5f5f5;
border-radius: 8px;
}
.filter-group {
margin-bottom: 1rem;
}
.filter-group label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
}
.badge {
background: #007bff;
color: white;
padding: 0.125rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
margin-left: 0.5rem;
}
.active-filters {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #ddd;
}
.filter-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.filter-tag {
background: white;
border: 1px solid #ddd;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
}
.filter-tag button {
background: none;
border: none;
cursor: pointer;
margin-left: 0.25rem;
color: #666;
}
.clear-all {
font-size: 0.875rem;
color: #666;
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
}
</style> 8. Event Handler Registry for Custom Actions
The Scenario: You’re building a reusable Svelte action that can attach multiple event listeners to an element. The action accepts an object where keys are event names and values are handler functions.
The Problem: Event names are strings provided by the consumer of your action. While most are standard DOM events, custom events could have any name.
The Solution:
// src/lib/actions/multiEvent.ts
import type { Action } from 'svelte/action'
/**
* Type for event handlers object.
* Keys are event names, values are handler functions.
*/
type EventHandlers = Record<string, (event: Event) => void>
/**
* A Svelte action that attaches multiple event listeners to an element.
*
* Usage:
* <div use:multiEvent={{ click: handleClick, mouseenter: handleHover }}>
*
* Why Object.create(null)?
* Event names are provided by the user of this action. While most are
* standard DOM events (click, mouseenter, etc.), the action should handle:
* - Custom events: 'my-custom-event', 'data:loaded'
* - Edge cases: 'constructor', 'toString' (unlikely but possible)
*
* Using Object.create(null) for internal storage ensures we correctly
* track cleanup functions for ANY event name.
*
* @param node - The DOM element to attach listeners to
* @param handlers - Object mapping event names to handler functions
*/
export const multiEvent: Action<HTMLElement, EventHandlers> = (node, handlers = {}) => {
/**
* Store cleanup functions for each event.
* Using Object.create(null) ensures we can use any event name as a key.
*/
const cleanups: Record<string, () => void> = Object.create(null)
/**
* Attach event handlers and store cleanup functions.
* @param eventHandlers - Object mapping event names to handlers
*/
function attach(eventHandlers: EventHandlers): void {
// First, clean up any existing handlers
for (const eventName of Object.keys(cleanups)) {
cleanups[eventName]()
delete cleanups[eventName]
}
// Attach new handlers
for (const [eventName, handler] of Object.entries(eventHandlers)) {
// Skip invalid handlers
if (typeof handler !== 'function') {
console.warn(`Invalid handler for event "${eventName}": expected function`)
continue
}
// Add the event listener
node.addEventListener(eventName, handler)
// Store cleanup function
cleanups[eventName] = () => {
node.removeEventListener(eventName, handler)
}
}
}
// Initial attachment
attach(handlers)
// Return action lifecycle methods
return {
/**
* Called when the handlers parameter changes.
* Re-attaches all event listeners.
*/
update(newHandlers: EventHandlers): void {
attach(newHandlers)
},
/**
* Called when the element is removed from the DOM.
* Cleans up all event listeners.
*/
destroy(): void {
for (const cleanup of Object.values(cleanups)) {
cleanup()
}
}
}
}
/**
* Convenience type for better autocompletion.
* Extend this if you want to add specific event types.
*/
export type StandardEventHandlers = {
click?: (event: MouseEvent) => void
mouseenter?: (event: MouseEvent) => void
mouseleave?: (event: MouseEvent) => void
focus?: (event: FocusEvent) => void
blur?: (event: FocusEvent) => void
keydown?: (event: KeyboardEvent) => void
keyup?: (event: KeyboardEvent) => void
[customEvent: string]: ((event: Event) => void) | undefined
} <!-- Example usage of the multiEvent action -->
<script lang="ts">
import { multiEvent } from '$lib/actions/multiEvent'
// Track interaction state
let clickCount = $state(0)
let isHovered = $state(false)
let lastEvent = $state('')
// Reference to the element (for dispatching custom events)
let boxElement: HTMLElement
// Define event handlers
// These can include any event names, including unusual ones
let eventConfig = $state({
// Standard DOM events
click: () => {
clickCount++
lastEvent = 'click'
},
mouseenter: () => {
isHovered = true
lastEvent = 'mouseenter'
},
mouseleave: () => {
isHovered = false
lastEvent = 'mouseleave'
},
// Custom events (might be dispatched by other components)
'data:loaded': () => {
lastEvent = 'data:loaded'
console.log('Data loaded event received!')
},
// Even event names that match object properties work!
'custom:constructor': () => {
lastEvent = 'custom:constructor'
console.log('Constructor event received!')
}
})
/**
* Dispatch a custom event for testing.
*/
function dispatchCustomEvent(eventName: string) {
if (boxElement) {
const event = new CustomEvent(eventName, { bubbles: true })
boxElement.dispatchEvent(event)
}
}
</script>
<div
class="interactive-box"
class:hovered={isHovered}
use:multiEvent={eventConfig}
bind:this={boxElement}
>
<p>Click me! Count: {clickCount}</p>
<p>Hovered: {isHovered ? 'Yes' : 'No'}</p>
<p>Last event: {lastEvent || 'None yet'}</p>
</div>
<!-- Buttons to trigger custom events for testing -->
<div class="test-buttons">
<button onclick={() => dispatchCustomEvent('data:loaded')}> Trigger data:loaded </button>
<button onclick={() => dispatchCustomEvent('custom:constructor')}>
Trigger custom:constructor
</button>
</div>
<style>
.interactive-box {
padding: 2rem;
background: #f0f0f0;
border: 2px solid #ccc;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.interactive-box.hovered {
background: #e0e0ff;
border-color: #66f;
}
.test-buttons {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
}
</style> Quick Reference: When to Use What
| Situation | Use {} | Use Object.create(null) |
|---|---|---|
| Keys are hardcoded in your code | Safe | Not needed |
| Keys come from user input | Risky | Safe |
| Keys come from URLs | Risky | Safe |
| Keys come from APIs or databases | Risky | Safe |
You need .toString() or other methods | Safe | Won’t have them |
| Building a cache or lookup table | Risky | Safe |
Using with Svelte 5 $state | Works | Works |
Common Questions
Can I use Map instead?
Yes! JavaScript’s Map is another safe option for key-value storage:
const safeMap = new Map()
safeMap.set('constructor', 'my value')
console.log(safeMap.get('constructor')) // 'my value' ✅ Svelte even provides a reactive SvelteMap in svelte/reactivity. However, Object.create(null) has some advantages:
- Familiar object syntax (
obj.keyorobj['key']) - Works directly with
$statefor reactivity - JSON-serializable (important for SSR in SvelteKit)
- Often more convenient for simple lookup tables
Does this work with TypeScript?
Absolutely! Use Record<string, T> for type safety:
const cache: Record<string, User> = Object.create(null)
cache['alice'] = { name: 'Alice', age: 30 } // ✅ Type-checked What about performance?
For most applications, the performance difference is negligible. Object.create(null) can actually be slightly faster for property lookups because JavaScript doesn’t need to check the prototype chain. But unless you’re doing millions of lookups, you won’t notice a difference.
Summary
Object.create(null) is a simple but powerful technique that every Svelte 5 and SvelteKit developer should know. Here’s when to reach for it:
- Always use it when keys come from untrusted sources (URLs, user input, APIs)
- Use it for caches, lookup tables, registries, and dictionaries
- Skip it when you control all keys and need inherited methods
In SvelteKit applications where data flows from URLs, forms, databases, and APIs, this pattern helps you avoid an entire category of subtle bugs. Combined with Svelte 5’s $state rune, you get reactive, type-safe dictionaries that handle any key safely.
The next time you write const cache = {}, ask yourself: “Where do these keys come from?” If the answer is “from the user” or “from external data,” make it Object.create(null) instead. Your future self will thank you!