@risklight/models
@risklight/models
Docs: https://risklight.github.io/models/
Framework-agnostic Model/Collection library with Proxy-based reactivity, a pluggable HTTP layer, Zod validation, and first-class TypeScript generics. Works standalone, with Vue 3, React, or Nuxt.
Drop-in replacement for vue-mc — same API, no framework lock-in.
Install
npm install @risklight/models
Quick start
TypeScript
import { Model, Collection } from '@risklight/models'
import { required, email, between } from '@risklight/models'
interface UserAttrs {
id: number
name: string
email: string
age: number
}
class User extends Model<UserAttrs> {
defaults(): Partial<UserAttrs> {
return { id: 0, name: '', email: '', age: 0 }
}
routes() {
return {
fetch: '/api/users/{id}',
save: '/api/users',
}
}
validation() {
return {
name: required,
email: required.and(email),
age: required.and(between(18, 120)),
}
}
}
class Users extends Collection<User> {
model() { return User }
routes() { return { fetch: '/api/users' } }
}
JavaScript
import { Model, Collection, required, email, between } from '@risklight/models'
class User extends Model {
defaults() {
return { id: 0, name: '', email: '', age: 0 }
}
routes() {
return {
fetch: '/api/users/{id}',
save: '/api/users',
}
}
validation() {
return {
name: required,
email: required.and(email),
age: required.and(between(18, 120)),
}
}
}
class Users extends Collection {
model() { return User }
routes() { return { fetch: '/api/users' } }
}
Attribute access
Models use a Proxy — read/write attributes with dot notation:
const user = new User({ name: 'John', email: 'john@test.com' })
user.name // 'John'
user.name = 'Jane' // sets attribute, emits 'change' event
user.get('name') // typed: string (with generics)
user.set('name', 'Bob')
user.set({ name: 'Bob', email: 'bob@test.com' })
Attribute access is typed from the generic: with class User extends Model<UserAttrs>, user.name is string, user.age is number, and assigning the wrong type is a compile error. A model declared without a generic keeps Record<string, any> attributes.
Attribute names may start with an underscore as long as they are declared in defaults() (Mongo's _id is the usual case). Undeclared _-prefixed keys are treated as private instance fields and never reach the attribute store.
State management
user.sync() // mark current state as "saved"
user.name = 'changed'
user.changed() // ['name']
user.saved('name') // 'Bob' (last synced value)
user.reset() // revert to synced state
user.changed() // false
Nested models
Cast attributes to model instances via mutations:
interface AddressAttrs {
street: string
city: string
zip: string
}
class Address extends Model<AddressAttrs> {
defaults() { return { street: '', city: '', zip: '' } }
}
class User extends Model {
defaults() {
return { name: '', address: {} }
}
mutations() {
return {
address: (v: any) => new Address(v),
}
}
}
const user = new User({ name: 'John', address: { street: '123 Main', city: 'Berlin', zip: '10115' } })
user.address.city // 'Berlin'
user.address.city = 'Munich'
Validation
With rules
import { required, email, min, max, length, match, integer } from '@risklight/models'
class User extends Model {
validation() {
return {
name: required.and(length(2, 50)),
email: required.and(email),
age: required.and(integer).and(min(18)),
phone: match(/^\+\d{10,15}$/),
}
}
}
const user = new User()
const errors = await user.validate()
// { name: 'Required', email: 'Required', age: 'Required' }
With Zod
Zod schemas take priority over validation() rules:
import { z } from 'zod'
class User extends Model {
defaults() { return { name: '', email: '' } }
schema() {
return z.object({
name: z.string().min(2),
email: z.string().email(),
})
}
}
Nested attributes
Errors for nested fields are keyed by the full dotted path ('basic_info.title', 'genres.1'), so each message can sit next to its own input. validation() rules accept the same dotted keys.
Rule combinators
// AND — both must pass
required.and(email)
// OR — at least one must pass
required.or(email)
// Custom message
required.format('This field cannot be empty')
Available rules
| Rule | Description |
|---|---|
required |
Not null/undefined/empty |
email |
Valid email |
url |
Valid URL |
uuid |
Valid UUID |
integer |
Integer number |
numeric |
Number or numeric string |
boolean |
Boolean value |
string |
String value |
array |
Array value |
object |
Plain object |
min(n) |
Number >= n |
max(n) |
Number <= n |
between(a, b) |
Number between a and b |
gt(n), gte(n), lt(n), lte(n) |
Comparisons |
length(min, max?) |
String/array length |
match(regex) |
Matches pattern |
equals(value) |
Strictly equal |
date |
Valid date |
after(date), before(date) |
Date comparisons |
alpha, alphanumeric |
Letter/number only |
ip, iso8601, base64, ascii |
Format checks |
creditcard |
Luhn algorithm check |
json |
Valid JSON string |
positive, negative |
Sign checks |
not(...values) |
Exclusion |
same(attr) |
Same as another attribute |
Localization
import { register, locale } from '@risklight/models'
register('de', {
required: 'Pflichtfeld',
email: 'Ungueltige E-Mail',
})
locale('de')
HTTP (CRUD)
HTTP goes through createRequest(): axios by default, or the transport configured with configureTransport() when you use ResourceModel/ResourceCollection (see REST resources). Override createRequest() on a model or collection to plug in anything else.
Model
class User extends Model {
defaults() { return { id: null, name: '', email: '' } }
routes() {
return {
fetch: '/api/users/{id}',
save: '/api/users',
delete: '/api/users/{id}',
}
}
}
// Create
const user = new User({ name: 'John', email: 'john@test.com' })
await user.save() // POST /api/users
console.log(user.id) // server-assigned ID
// Read
const user2 = new User({ id: 5 })
await user2.fetch() // GET /api/users/5
// Update
user.name = 'Jane'
await user.save() // PUT /api/users/5
// Delete
await user.delete() // DELETE /api/users/5
Collection
const users = new Users()
await users.fetch() // GET /api/users
users.models // User[]
users.size() // number
Pagination
const users = new Users()
users.page(1)
await users.fetch() // GET /api/users?page=1
users.page(2)
await users.fetch() // GET /api/users?page=2
users.isLastPage() // true/false
File upload
class Avatar extends Model {
defaults() { return { id: null, filename: '' } }
routes() { return { save: '/api/upload' } }
}
const model = new Avatar()
await model.upload({
data: { file: fileInput.files[0], field: 'avatar' }
})
Backend validation (422)
When the server returns 422, errors are automatically parsed into model.errors:
try {
await user.save()
} catch (e) {
console.log(user.errors)
// { name: ['Name is required'], email: ['Invalid email'] }
}
Bulk operations
// Bulk save — saves all changed models in one request
for (const user of users.models) {
user.name = user.name.toUpperCase()
}
await users.save()
// Bulk delete — mark models then delete
users.models[0].deleting = true
users.models[2].deleting = true
await users.delete()
REST resources
ResourceModel and ResourceCollection bind a model to a REST resource by a static route and route HTTP through a transport you configure once. They work in any framework: plain JS, Vue, React or Nuxt. Without configureTransport() they fall back to the default axios request.
import { ResourceModel, ResourceCollection, configureTransport } from '@risklight/models'
import type { Identified } from '@risklight/models'
configureTransport({
fetcher: async ({ url, method, data, params, headers }) => {
const res = await fetch(url + '?' + new URLSearchParams(params as any), { method, body: data ? JSON.stringify(data) : undefined, headers })
return { data: await res.json(), status: res.status }
},
// unwrap: (payload) => payload.data // default: unwraps { data } envelopes
// identifier: '_id' // default
})
interface GenreAttrs extends Identified { name: string; description?: string }
class Genre extends ResourceModel<GenreAttrs> {
static route = '/api/genre'
defaults(): Partial<GenreAttrs> { return { _id: '', name: '', description: '' } }
}
class Genres extends ResourceCollection<Genre> {
model() { return Genre }
}
const genre = new Genre()
await genre.fetchOne('a1') // GET /api/genre/a1
genre.name = 'Landscape'
await genre.save() // PUT /api/genre/a1 (POST /api/genre when new, without the identifier)
await genre.delete() // DELETE /api/genre/a1
const genres = new Genres()
await genres.fetchAll() // GET /api/genre → genres.models / genres.items (plain objects)
await Genre.fetchAll() // { data: GenreAttrs[] } without instantiating models
Routes: fetch/update/delete use {route}/{identifier}, save on a new model posts to {route}. The identifier defaults to _id; pass identifier to configureTransport() for id or anything else.
Options
class User extends Model {
options() {
return {
identifier: 'id', // primary key attribute
patch: false, // use PATCH instead of PUT for updates
saveUnchanged: true, // send unchanged models to server
useFirstErrorOnly: false, // single error string vs array per field
validateOnChange: false, // auto-validate on attribute change
validateRecursively: true, // validate nested models
mutateOnChange: false, // apply mutations on change
mutateBeforeSync: true, // mutate before syncing state
mutateBeforeSave: true, // mutate before save request
}
}
}
Undeclared attribute protection
Writing to an attribute not declared in defaults() triggers a console warning:
class User extends Model {
defaults() { return { name: '', email: '' } }
}
const user = new User()
user.phone = '123' // ⚠ [models] Undeclared "phone" on User
The attribute is still stored — but the warning helps catch typos and unintended properties. Control this via the debug option:
class User extends Model {
options() {
return {
debug: true, // console.warn (default)
// debug: 'strict', // throw Error instead
// debug: false, // silent
}
}
}
Events
const user = new User({ name: 'John' })
user.sync()
// Attribute changes
user.on('change', (ctx) => {
console.log(ctx.attribute, ctx.value, ctx.previous)
})
user.on('change:name', (ctx) => {
console.log('name changed to', ctx.value)
})
// Lifecycle
user.on('save', () => console.log('save started'))
user.on('save.success', () => console.log('saved'))
user.on('save.failure', () => console.log('save failed'))
user.on('fetch', () => console.log('fetched'))
user.on('delete', () => console.log('deleted'))
user.on('sync', () => console.log('synced'))
user.on('reset', () => console.log('reset'))
// Property signals
user.on('name', (value, previous) => {
console.log(`name: ${previous} -> ${value}`)
})
// Wildcard
user.on('*', (key, value, previous) => {
console.log(`${key} changed`)
})
// Unsubscribe
const handler = () => {}
user.on('change', handler)
user.off('change', handler)
Collection API
const users = new Users()
// Add/remove
users.add({ name: 'John', email: 'j@t.com' })
users.add(new User({ name: 'Jane' }))
users.remove(user)
users.clear()
// Query
users.find(u => u.name === 'John') // User | undefined
users.where(u => u.age > 18) // User[]
users.filter(u => u.age > 18) // new Collection<User>
users.has(user) // boolean
// Array-like
users.models // User[]
users.size() // number
users.isEmpty() // boolean
users.first() // User | undefined
users.last() // User | undefined
users.map(u => u.name)
users.each(u => console.log(u.name))
users.sort('name')
// Iteration
for (const user of users) {
console.log(user.name)
}
// Serialization
users.toJSON() // plain object array
users.clone() // new collection with cloned models
HTTP customization
Override methods for custom behavior:
class User extends Model {
// Custom headers
getDefaultHeaders() {
return { Authorization: `Bearer ${getToken()}` }
}
// Custom query params
getFetchQuery() {
return { include: 'posts,comments' }
}
// Custom save data (e.g. wrap in root key)
getSaveData() {
return { user: this.attributes }
}
// Custom URL logic
getFetchURL() {
return `/api/v2/users/${this.id}`
}
// PATCH mode
options() {
return { patch: true }
}
}
Custom query string serializer
For nested params (e.g. with qs):
import qs from 'qs'
class User extends Model {
options() {
return {
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
}
}
}
Lifecycle hooks
Override these to customize request behavior:
class User extends Model {
async onSave() {
// Return REQUEST_CONTINUE, REQUEST_SKIP, or REQUEST_REDUNDANT
if (this.loading) return Model.REQUEST_SKIP
return super.onSave()
}
onSaveSuccess(response) {
super.onSaveSuccess(response)
console.log('Saved!', response.getData())
}
onSaveFailure(error) {
super.onSaveFailure(error)
notify('Save failed')
}
onFetchSuccess(response) {
super.onFetchSuccess(response)
// Transform response data
}
}
Framework adapters
Vue 3
Import the adapter once in your main.ts — all Model instances become Vue-reactive automatically:
// main.ts
import '@risklight/models/vue'
<script setup>
import { ref } from 'vue'
import { User } from './models/User'
const user = ref(new User({ name: 'John' }))
// user.value.name is reactive in templates
</script>
<template>
<input v-model="user.name" />
<p>{{ user.name }}</p>
</template>
Validation errors and the loading, saving, deleting and fatal flags are reactive too, whether the model sits in ref(), reactive() or a plain variable, so a template can show the message for a field as soon as validate() or save() fills it:
<template>
<input v-model="genre.name" />
<p v-if="genre.errors.name">{{ genre.errors.name[0] }}</p>
<button :disabled="genre.saving" @click="genre.save()">Save</button>
</template>
React
Use the useModelState hook for reactive re-renders:
import { useMemo } from 'react'
import { useModelState } from '@risklight/models/react'
import { User } from './models/User'
function UserForm() {
const user = useMemo(() => new User({ name: 'John' }), [])
const state = useModelState(user)
return (
<>
<input
value={state.name}
onChange={e => user.name = e.target.value}
/>
<p>{state.name}</p>
</>
)
}
useModelState returns a reactive snapshot of model attributes. It re-renders on:
- attribute changes (including nested models)
sync,reset,fetch,save.success,delete
Nuxt
@risklight/models/nuxt is ResourceModel/ResourceCollection plus the Vue adapter, exported under Nuxt names (NuxtModel, NuxtCollection, configureNuxtModels). The only Nuxt-specific piece is the fetcher, which lives in your app because it needs $fetch and useNuxtApp():
// plugins/models.ts
import { configureNuxtModels } from '@risklight/models/nuxt'
export default defineNuxtPlugin(() => {
configureNuxtModels({
fetcher: async ({ url, method, data, params, headers }) => {
const fetcher = import.meta.server ? $fetch : (useNuxtApp().$csrfFetch as typeof $fetch)
const res = await fetcher.raw(url, { method: method as any, body: data as any, query: params, headers })
return { data: res._data, status: res.status, headers: Object.fromEntries(res.headers.entries()) }
}
})
})
import { NuxtModel, NuxtCollection } from '@risklight/models/nuxt'
import type { Identified } from '@risklight/models'
class Genre extends NuxtModel<GenreAttrs> {
static route = '/api/genre'
defaults(): Partial<GenreAttrs> { return { _id: '', name: '', description: '' } }
}
SSR goes through $fetch, the client through $csrfFetch with cookies and CSRF headers. See REST resources for the full API.
Vanilla JS / Node.js
Works without any adapter:
import { Model } from '@risklight/models'
class User extends Model {
defaults() { return { name: '' } }
routes() { return { save: '/api/users' } }
}
const user = new User({ name: 'John' })
user.on('change', (ctx) => console.log(ctx.attribute, ctx.value))
user.name = 'Jane' // logs: 'name' 'Jane'
await user.save()
Exported types
import type {
Routes,
Options,
RequestOptions,
Listener,
Mutation,
RouteResolver,
HttpMethod,
ResponseData,
RequestSuccessCallback,
RequestFailureCallback,
} from '@risklight/models'
Base classes and constructors
Model and ResourceModel are typed constructors that return ModelBase<A> & A / ResourceModelBase<A> & A, which is what gives dot access its types. The underlying classes and constructor types are exported for tooling and generic constraints:
import type { ModelBase, ModelConstructor } from '@risklight/models'
import type { ResourceModelBase, ResourceModelConstructor } from '@risklight/models'
function describe<M extends ModelBase<any>>(model: M) { return model.toJSON() }
Transport and resource types
import type { Fetcher, RequestConfig, RawResponse, TransportOptions, Identified, Attributes } from '@risklight/models'
import type { ResourceModelConstructor } from '@risklight/models'
const fetcher: Fetcher = async (config: RequestConfig): Promise<RawResponse> => { /* ... */ }
interface GenreAttrs extends Identified { name: string } // Identified is { _id?: string }
type GenreRow = Attributes<Genre> // toJSON() shape of any model; what ResourceCollection#items yields
Error handling
import { ValidationError, RequestError, ResponseError } from '@risklight/models'
try {
await user.save()
} catch (e) {
if (e instanceof ValidationError) {
// Client-side validation failed
console.log(e.getValidationErrors())
}
if (e instanceof ResponseError) {
// Server returned error (e.g. 422)
console.log(e.getResponse()?.getStatus())
}
if (e instanceof RequestError) {
// Network/request error
console.log(e.getError())
}
}
License
MIT
Related Packages
Vue 3 composables and Pinia stores for CRUD screens: a normalized record store,...
Nuxt module implementing Precognition protocol (inspired by official Laravel Pre...
Vue plugin adding layers of ORM (ModelsStore, Model, Relation, Query), Api (+moc...
Parse, validate and transform data with confidence in nuxt using `zod`
Version History
| Version | Released | License |
|---|---|---|
| 0.4.1 | MIT | |
| 0.4.0 | MIT | |
| 0.3.1 | MIT | |
| 0.3.0 | MIT | |
| 0.2.2 | MIT | |
| 0.2.1 | MIT | |
| 0.2.0 | MIT | |
| 0.1.3 | MIT | |
| 0.1.2 | MIT | |
| 0.1.1 | MIT | |
| 0.1.0 | MIT |