Skip to Content
GuidesClient GuidesDefining Models

Defining Models

Create type-safe, reactive models with TypeScript decorators.

Basic Model

models/User.ts
import { Model, ClientModel, Property } from '@gluonic/client' @ClientModel('user') export class User extends Model { // Auto-generated (type inferred) @Property() id = crypto.randomUUID() // Required fields @Property() name!: string @Property() email!: string // Timestamps @Property() createdAt = Date.now() @Property({ defaultOn: 'update' }) updatedAt = Date.now() // Server-managed (read-only) @Property({ server: true }) serverTimestamp?: number }

Key points:

  • Auto-generated fields use initializers (type inferred)
  • Required fields use ! (must provide in constructor)
  • Optional fields use ?
  • defaultOn: 'update' for fields that update on every save

v1.1+ Features: defaultOn: 'update' and simplified syntax shown above.

Current: Use defaultOn: ['create', 'save'] with defaultProvider for update timestamps.

Relationships

import { ManyToOne, OneToMany, Collection } from '@gluonic/client' import type { User, Comment } from './models' @ClientModel('post') export class Post extends Model { @Property() id = crypto.randomUUID() @Property() title!: string @Property() authorId?: string // Optional FK // Many-to-One: post.author → User @ManyToOne('user', 'authorId', 'posts') author?: User // Optional relationship (matches FK) // One-to-Many: post.comments → Comment[] @OneToMany('comment', 'post') comments = new Collection<Comment>() }

Note: Type signatures use the model class (User) and Collection, but at runtime these become LazyReference<User> and LazyCollection<Comment>. Access them the same way:

const authorName = post.author?.value?.name const commentCount = post.comments.length

Computed Properties

@ClientModel('post') export class Post extends Model { @Property() content: string = '' // Computed property (no decorator) get wordCount(): number { return this.content.split(/\s+/).length } get readTime(): string { const minutes = Math.ceil(this.wordCount / 200) return `${minutes} min read` } get isLongForm(): boolean { return this.wordCount > 1000 } } // In component <Text>{post.readTime}</Text> // "5 min read"

Field Patterns

Auto-Generated Fields (Type Inferred)

Use initializers for auto-generated values - TypeScript infers the type:

@Property() id = crypto.randomUUID() // Type: string (inferred) @Property() clientId = `client-${crypto.randomUUID()}` // Type: string (inferred) @Property() createdAt = Date.now() // Type: number (inferred)

No need for : string = '' - type is inferred from initializer!

Required Fields (Use !)

Fields users must provide in constructor:

@Property() title!: string // Required in new Task({ title: '...' }) @Property() email!: string // Required // TypeScript enforces new Task({}) // ❌ Error: title required new Task({ title: 'Hello' }) // ✅ Correct

Optional Fields (Use ?)

Fields users can omit:

@Property() description?: string // Optional in constructor @Property() metadata?: Record<string, any> // Optional // Both valid new Task({ title: 'Hello' }) // âś… description omitted new Task({ title: 'Hello', description: 'Desc' }) // âś… provided

Static Defaults

Use initializers for default values:

@Property() status = 'draft' // Type: string (inferred) @Property() done = false // Type: boolean (inferred) @Property() priority = 0 // Type: number (inferred)

Update Timestamps (v1.1+)

Fields that re-compute on every save:

@Property({ defaultOn: 'update' }) updatedAt = Date.now() @Property({ defaultOn: 'update' }) lastModified = Date.now() // Re-computed on create AND every save

v1.1+ Feature: defaultOn: 'update' is planned.

Current workaround:

@Property({ defaultOn: ['create', 'save'], defaultProvider: () => Date.now() }) updatedAt: number = 0

Complete Example

@ClientModel('task') export class Task extends Model { // Auto-generated (inferred types) @Property() id = crypto.randomUUID() @Property() createdAt = Date.now() // Required (explicit types with !) @Property() title!: string // Optional (explicit types with ?) @Property() description?: string @Property() dueDate?: number @Property() authorId?: string // Static defaults (inferred types) @Property() done = false @Property() status = 'draft' // Update timestamps (v1.1+) @Property({ defaultOn: 'update' }) updatedAt = Date.now() // Server-managed (read-only) @Property({ server: true }) serverCreatedAt?: number @Property({ server: true }) serverUpdatedAt?: number // Relationships @ManyToOne('user', 'authorId', 'tasks') author?: User // Runtime: LazyReference<User> @OneToMany('comment', 'task') comments = new Collection<Comment>() // Runtime: LazyCollection<Comment> }

Creating Model Instances

OOP Style (v1.1+)

Create instances directly and save:

// Create with required fields const task = new Task({ title: 'New Task' }) // ID auto-generated âś“ console.log(task.id) // UUID from initializer // Mutate as needed task.description = 'Add details later' task.done = true // Save (detects new vs existing automatically) await task.save()

TypeScript enforces required fields:

new Task({}) // ❌ Error: title required new Task({ title: 'Hello' }) // ✅ Correct

v1.1+ Feature: OOP pattern with new Task() and task.save().

Current: Use functional style through store methods.

Functional Style (Current)

Create through store methods:

// Generate ID manually const id = crypto.randomUUID() await store.create('task', id, { title: 'New Task', done: false }) // Later, get instance const task = useModel<Task>('task', id) // Update through store await store.save('task', id, { description: 'Add details', done: true })

Both styles work - choose what fits your codebase!

Decorator Reference

API Reference: Model · @ClientModel · @Property

@ClientModel(typeName)

Registers a model class:

@ClientModel('user') export class User extends Model { ... } // typeName: Wire format type ('user', 'post', etc.) // Must match server model name

@Property(options?)

Marks a syncable property:

@Property() id: string = '' @Property({ server: true }) createdAt: number = 0 // Read-only @Property({ validate: (val) => val.length > 0 }) title: string = '' // Validated on write @Property({ defaultOn: ['create'], defaultProvider: () => Date.now() }) timestamp: number = 0 // Auto-set on create

Options:

  • server?: boolean - Read-only field from server
  • validate?: (value) => boolean - Validation function
  • computed?: boolean - Derived property (not in pool)
  • defaultOn?: Array<'create' | 'save'> - When to apply default
  • defaultProvider?: (ctx) => any - Generate default value

@ManyToOne(relatedType, foreignKey, inverse)

Many-to-one relationship:

@Property() authorId?: string // Optional FK @ManyToOne('user', 'authorId', 'posts') author?: User // Optional relationship (matches FK) // Parameters: // - relatedType: Type name from @ClientModel ('user') // - foreignKey: FK field on this model ('authorId') // - inverse: Collection name on related model ('posts' on User) [optional] // Access: const name = post.author?.value?.name

Rule: FK optionality must match relationship optionality:

  • authorId!: string + author!: User = Required relationship
  • authorId?: string + author?: User = Optional relationship

@OneToMany(relatedType, inverse)

One-to-many relationship:

@OneToMany('post', 'author') posts = new Collection<Post>() // Parameters: // - relatedType: Type name from @ClientModel ('post') // - inverse: Property name on related model ('author' on Post) [optional] // Access: const postTitles = user.posts.map(p => p.title)

Without Decorators (Alternative)

If you can’t use decorators (build tool issues):

models/User.ts
import { Model, registerModel } from '@gluonic/client' export class User extends Model { id: string = '' email: string = '' name: string = '' createdAt: number = 0 } // Register manually registerModel(User, { type: 'user', properties: { id: { type: 'primitive' }, email: { type: 'primitive' }, name: { type: 'primitive' }, createdAt: { type: 'primitive', server: true } } })

Model Registry

Warm Up Registry

models/index.ts
import { warmUpModelRegistry } from '@gluonic/client' import { User } from './User' import { Post } from './Post' import { Comment } from './Comment' // Warm up on module load warmUpModelRegistry([User, Post, Comment]) // Or in SyncClient SyncClient({ models: [User, Post, Comment] // Auto-warms })

TypeScript Configuration

tsconfig.json

{ "compilerOptions": { // For TypeScript 5 decorators "experimentalDecorators": false, "useDefineForClassFields": false, // CRITICAL! // Standard settings "strict": true, "esModuleInterop": true, "skipLibCheck": true } }

Vite (Web)

import { vitePluginTypescriptTranspile } from 'vite-plugin-typescript-transpile' export default defineConfig({ plugins: [ vitePluginTypescriptTranspile(), // FIRST - handles decorators react() // SECOND - handles JSX ] })

Metro (React Native)

// metro.config.js module.exports = { transformer: { // Decorators work out of box âś“ babelTransformerPath: require.resolve('react-native/metro-babel-transformer') } }

Field Types

Primitives

@Property() id: string = '' @Property() count: number = 0 @Property() active: boolean = false @Property() data: any = null // Avoid any when possible

Dates

Store as timestamps (numbers):

@Property() createdAt: number = 0 // In component, convert to Date const date = new Date(user.createdAt)

Arrays

@Property() tags: string[] = [] @Property() scores: number[] = [] // Stored as JSON in SQLite

Objects

@Property() metadata: Record<string, any> = {} @Property() config: { theme: string; lang: string } = { theme: 'dark', lang: 'en' }

Validation

Field-Level

@Property({ validate: (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) }) email: string = '' // Write is rejected if validation fails await store.save('user', id, { email: 'invalid' }) // Throws validation error

Model-Level

@ClientModel('post') export class Post extends Model { @Property() title: string = '' @Property() content: string = '' validate(): string | null { if (this.title.length === 0) { return 'Title is required' } if (this.content.length < 10) { return 'Content too short' } return null } } // Before saving const error = post.validate() if (error) { alert(error) } else { await store.save('post', post.id, { ... }) }

API Reference: SyncClient · warmUpModelRegistry

Next Steps

Last updated on