@risklight/models

Framework-agnostic Model/Collection with Proxy-based reactivity and Zod validation
1,845
Install
npm install @risklight/models
Latest Version:0.4.1
Vue:3
License:MIT
Last Updated:Sep 29, 2026
Links: GitHub  ·  npm
Maintainer: risklight

@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

@guebbit/vue-toolkit

Vue 3 composables and Pinia stores for CRUD screens: a normalized record store,...

2,654 0
nuxt-precognition

Nuxt module implementing Precognition protocol (inspired by official Laravel Pre...

3,621 18
evas-vue-store-plugin

Vue plugin adding layers of ORM (ModelsStore, Model, Relation, Query), Api (+moc...

4,182 0
evas-vue

Vue plugin adding layers of ORM (ModelsStore, Model, Relation, Query), Api (+moc...

1,513
@sidebase/nuxt-parse

Parse, validate and transform data with confidence in nuxt using `zod`

12,669 54

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