Svelte 5 Integration
QuickModel integrates with Svelte 5 and SvelteKit through runes, stores, and form actions. Use plain classes for form validation and QModel for reactive state and server-side coercion.
Key Separation
| Use case | Approach |
|---|---|
Svelte 5 $state reactive form | Plain TS class + @QRule + qCheckRules() |
$derived computed from model | QModel + $qSerialize() + getter |
| SvelteKit form actions | QModel + @Quick() + $qCheckRules() |
Svelte stores (writable) | QModel wrapped in a writable store |
Installation
bash
npm install quickmodelEnable decorators in tsconfig.json:
json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}Svelte 5 Runes — $state + $derived
svelte
<script lang="ts">
import { QModel, Quick, QComputed } from 'quickmodel';
@Quick(
{ id: 'string', title: 'string', body: 'string', pinned: 'boolean', createdAt: Date },
{ unknownPropertyPolicy: 'strip' }
)
class NoteModel extends QModel<INote> {
declare id: string;
declare title: string;
declare body: string;
declare pinned: boolean;
declare createdAt: Date;
@QComputed()
get preview(): string { return this.body.slice(0, 60); }
@QComputed()
get charCount(): number { return this.body.length; }
}
// $state wraps a QModel instance
let note = $state(new NoteModel({
id: 'n1', title: 'Hello', body: 'Initial content',
pinned: false, createdAt: new Date(),
}));
// @QComputed getters are TypeScript class getters — access them directly on the instance
let preview = $derived(note.preview); // string — inferred from NoteModel
let charCount = $derived(note.charCount); // number — inferred from NoteModel
function updateBody(newBody: string) {
// $qCopy() is IMMUTABLE — reassign the $state variable
note = note.$qCopy({ body: newBody });
}
</script>
<textarea
value={note.body}
oninput={e => updateBody(e.target.value)}
/>
<p>Preview: {preview}</p>
<p>Characters: {charCount}</p>Immutable merge with $state
Since $qCopy()returns a new instance, Svelte's$state reactivity fires automatically when you reassign the variable. This is the recommended pattern.
Form Validation — Plain Class
For lighter form validation without coercion or serialization, use a plain class with @QRule:
svelte
<!-- ContactForm.svelte -->
<script lang="ts">
import { QField, QRule } from 'quickmodel';
import { qCheckRules } from 'quickmodel/forms';
class ContactForm {
@QField({ label: 'Name', required: true })
@QRule((v: string) => v.trim().length >= 2, 'Name must be at least 2 characters')
name = '';
@QField({ label: 'Email', widget: 'email' })
@QRule((v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), 'Invalid email')
email = '';
}
let form = $state(new ContactForm());
let validation = $derived(qCheckRules(form));
</script>
<input bind:value={form.name} />
{#if validation.errors.find(e => e.field === 'name')}
<span>{validation.errors.find(e => e.field === 'name')?.message}</span>
{/if}Two patterns
- Plain class (above):
@QRule+@QFieldonly — noQModelinheritance. Minimum overhead for form validation. QModel+@Quick(above): adds coercion,$qCopy(),$qSerialize(),@QComputed. Use for reactive state management.
Svelte Stores (Svelte 4 / compatible with Svelte 5)
typescript
// stores/taskStore.ts
import { writable } from 'svelte/store';
import { QModel, Quick } from 'quickmodel';
@Quick(
{ id: 'string', label: 'string', done: 'boolean' },
{ unknownPropertyPolicy: 'keep' }
)
class TaskModel extends QModel<ITask> {
declare id: string;
declare label: string;
declare done: boolean;
}
function createTaskStore(initial: ITask) {
const { subscribe, update } = writable(new TaskModel(initial));
return {
subscribe,
toggle: () => update((task) => task.$qCopy({ done: !task.done })),
setLabel: (label: string) => update((task) => task.$qCopy({ label })),
};
}
export const task = createTaskStore({
id: 't1',
label: 'Buy milk',
done: false,
});svelte
<script>
import { task } from './stores/taskStore';
</script>
<input type="checkbox" checked={$task.done} onchange={() => task.toggle()} />
<span>{$task.label}</span>SvelteKit Form Actions
Validate and coerce incoming form data on the server:
typescript
// src/routes/newsletter/+page.server.ts
import type { Actions } from './$types';
import { fail } from '@sveltejs/kit';
import { qCheckRules } from 'quickmodel/forms';
class NewsletterForm {
@QRule(
(v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
'Invalid email address'
)
email = '';
@QRule((v: string) => v.trim().length >= 2, 'Name required')
name = '';
@QRule(
(v: string) => ['daily', 'weekly', 'monthly'].includes(v),
'Invalid frequency'
)
frequency = 'weekly';
}
export const actions: Actions = {
subscribe: async ({ request }) => {
const data = await request.formData();
const form = new NewsletterForm();
form.email = (data.get('email') as string) ?? '';
form.name = (data.get('name') as string) ?? '';
form.frequency = (data.get('frequency') as string) ?? 'weekly';
const validation = qCheckRules(form);
if (!validation.valid) {
const errors = Object.fromEntries(
validation.errors.map((e) => [e.field, e.message])
);
return fail(422, { errors });
}
// proceed with valid data
return { success: true };
},
};SvelteKit Load — createMany() for SSR
typescript
// src/routes/events/+page.server.ts
import type { PageServerLoad } from './$types';
import { QModel, Quick, QComputed } from 'quickmodel';
@Quick(
{
id: 'string',
name: 'string',
startDate: Date,
endDate: Date,
capacity: 'number',
},
{ unknownPropertyPolicy: 'strip' }
)
class EventModel extends QModel<IEvent> {
declare id: string;
declare name: string;
declare startDate: Date;
declare endDate: Date;
declare capacity: number;
@QComputed()
get durationDays(): number {
return Math.ceil(
(this.endDate.getTime() - this.startDate.getTime()) /
(1000 * 60 * 60 * 24)
);
}
}
export const load: PageServerLoad = async ({ fetch }) => {
const raw = await fetch('/api/events').then((r) => r.json());
const { instances } = EventModel.createMany(raw);
return { events: instances.map((evt) => evt.$qSerialize()) };
};Async Validation in SvelteKit
typescript
// Async slug uniqueness check
class BlogPostForm {
@QRule(async (slug: string) => {
const res = await fetch(`/api/slugs/check?slug=${slug}`);
const { available } = await res.json();
return available;
}, 'Slug already in use')
@QRule((v: string) => /^[a-z0-9-]+$/.test(v), 'Invalid slug format')
slug = '';
}
// In a +page.server.ts action using qCheckRulesAsync
import { qCheckRulesAsync } from 'quickmodel/forms';
const result = await qCheckRulesAsync(form, { mode: 'serial' });$qDiff() — Track Form Changes
typescript
const original = new ArticleModel({ ... });
// User edits
const edited = original.$qCopy({ title: 'Updated Title' });
original.$qDiff(edited);
// → { title: { before: 'Old Title', after: 'Updated Title' } }