Defining Models
Create type-safe, reactive models with TypeScript decorators.
With Decorators (Recommended)
Basic Model
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.lengthComputed 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' }) // âś… CorrectOptional 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' }) // âś… providedStatic 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 savev1.1+ Feature: defaultOn: 'update' is planned.
Current workaround:
@Property({
defaultOn: ['create', 'save'],
defaultProvider: () => Date.now()
})
updatedAt: number = 0Complete 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' }) // âś… Correctv1.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 createOptions:
server?: boolean- Read-only field from servervalidate?: (value) => boolean- Validation functioncomputed?: boolean- Derived property (not in pool)defaultOn?: Array<'create' | 'save'>- When to apply defaultdefaultProvider?: (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?.nameRule: FK optionality must match relationship optionality:
authorId!: string+author!: User= Required relationshipauthorId?: 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):
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
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 possibleDates
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 SQLiteObjects
@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 errorModel-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
- React Hooks - Use models in components
- Mutations - Create, update, delete
- Relationships - Lazy loading