Skip to main content

Models

What you'll learn
  • Defining basic models with attributes and relations
  • Extending your models with custom properties
  • Registering hooks on models

Model factory

makeModel is the default model factory function. It defines a new model using 2 arguments and returns an ES6 class:

  • The string type or a configuration object.
  • The optional definition of the model: an object map containing attributes/relations definitions and custom properties and methods.

The attributes and relations definition represents the schema of the model.

import { makeModel, attr, hasMany, toDate } from 'foscia/core';

makeModel('posts', {
/* The model definition */
title: attr<string>(),
description: attr<string>(),
publishedAt: attr<Date | undefined>(toDate()),
comments: hasMany<Comment>(),
get published() {
return !!this.publishedAt;
},
});

Extending a model class

makeModel will return a model class which can be extended by an ES6 class.

export default class Post extends makeModel('posts') {}

The returned model class also provides static methods to extend the definition already provided to makeModel.

/* Initial model creation without definition */
makeModel('posts')
.extends({
title: attr<string>(),
description: attr<string>(),
})
.extends({
publishedAt: attr<Date | undefined>(toDate()),
get published() {
return !!this.publishedAt;
},
});

This can be useful when sharing common features across models: creation timestamps, client side ID generation, etc.

If you wish to learn more about the composition capabilities of models, you should read the advanced guide about models composition.

Note on exported value

In many Foscia guides and examples, you will see that the ES6 class returned by makeModel is extended before exporting: we use export default class Post extends makeModel... instead of export default makeModel....

This has two benefits:

  • When using TypeScript, it allows to only import the type of the class using import type Post from './models/post' and avoids circular dependencies when models have circular relationships
  • It gives you more flexibility as you can quickly add custom properties and methods in the future

However, both syntax are valid. Use the one you prefer! 🦄

Using models classes

Model classes can be used like any ES6 class. It can be instantiated, manipulated, etc. Properties will be defined on each instance from the model definition.

const post = new Post();
post.title = 'Hello World!';
post.publishedAt = new Date();
console.log(post.title); // "Hello World!"
console.log(post.published); // true
console.log(post.exists); // false
info

Please note that most model's interaction (fetching, updating, etc.) are done through actions, you can read the actions guide to learn more about those.

Foscia proposes you multiple utilities functions to interact with models

Read the models' utilities API guide

Definition

caution

You must not use id, lid, type or exists as key of any attributes/relations/properties as those keys are internally used by Foscia or may be used by dependencies (e.g. JSON:API adapter).

IDs

id is an ID definition factory function used to define your model's IDs property. It takes 1 argument.

Foscia consider your IDs as string, number or null values by default. Each model have id and lid properties representing record identification. If you want to change the typing of those properties or transform values, you can use the id function.

import { id, toString } from 'foscia/core';

id<string>(); // Without config.
id(toString()); // With a transformer.

Transform

IDs transform works the same as attributes transform.

caution

id can only be used on id and lid keyed model's properties. There is currently no way of aliasing an ID in Foscia. Please fill an issue if this is something you need.

Attributes

attr is an attribute definition factory function used to define your model's attributes. It takes 0 to 2 arguments, depending on what you want to do.

Foscia consider your attributes as non-nullable values by default. When one of your model contains nullable attributes, you can pass a type to the factory (e.g. attr<string | null>()) or set a default attribute value (e.g. attr({ default: '' })).

import { attr, toDate } from 'foscia/core';

attr<string>(); // Without config.
attr(toDate()); // With a transformer.
attr({ default: null, transformer: toDate() }); // With config.
attr(toDate(), { sync: 'retrieve' }); // With a transformer and config.
attr({ default: () => [] }); // With a factory default (required for objects props).

Configuration

You can customize your attribute with the following options:

KeyTypeDefaultDescription
transformerTransform<T>undefinedThe transformer for the property's value when interacting with your backend.
defaultTundefinedThe default value for the property when initializing a model instance.
aliasstringundefinedAlias of your backend key used for (de)serializing.
syncboolean|'retrieve'|'write'trueAvoid retrieving/writing the property's value from/to the backend.

Transform

You can use a transformer to convert an attribute value when (de)serializing from/to your data source. There are two types or transformer within Foscia:

  • FunctionTransform: a function called to transform the value when serializing or deserializing.
  • ObjectTransform: an object with two methods: serialize and deserialize.

Foscia propose you 4 transformers out of the box: toDate, toNumber, toBoolean and toString.

info

You may need other transformers in your implementation, for example when you are using a library to manage dates (momentjs, dayjs, etc.). You may read the advanced guide on transformers to learn more about those.

Relations

hasMany and hasOne are relation definition factory function used to define your model's relations. As suggested by their names, hasMany represents a relation to a list of models and hasOne represents a relation to a single model. Those take 0 to 1 argument, depending on what you want to do.

Foscia consider your relations as non-nullable values by default. When one of your model contains nullable relations, you can pass a type to the factory (e.g. hasOne<User | null>()) or set a default relation value (e.g. hasOne({ default: null as User | null })).

Also consider that non-loaded relations will have a value of undefined.

import { hasOne, hasMany } from 'foscia/core';
import type User from './user';
import type Comment from './comment';

hasOne<User>(); // Without config.
hasOne<User>({ sync: 'retrieve' }); // With config.
hasOne<User>('users'); // With explicit type.
hasOne<User>({ type: 'users', sync: 'retrieve' }); // With explicit type.

hasMany<Comment>(); // Without config.
hasMany<Comment>({ sync: 'retrieve' }); // With config.
hasMany<Comment>('comments'); // With explicit type.
hasMany<Comment>({ type: 'comments', sync: 'retrieve' }); // With explicit type.
tip

When using TypeScript, you should define the type of the relation to get a type safe model. We suggest you to use import type to avoid creating circular dependencies when having circular model relations.

Configuration

You can customize your relation with the following options:

KeyTypeDefaultDescription
defaultTundefinedThe default value for the property when initializing a model instance.
aliasstringundefinedAlias of your backend key used for (de)serializing.
pathstringundefinedPath used to query the relation. Defaults to the relation's key.
typestringundefinedThe explicit type of related model. Might be used by the deserializer to know which model to instantiate.
syncboolean|'retrieve'|'write'trueAvoid retrieving/writing the property's value from/to the backend.

Readonly properties

You can make any ID, attribute or relation readonly using the readOnly modifier function. readOnly will work on id, attr, hasOne and hasMany factories.

Making a property readonly will throw an error when trying to affect a value to it, and emit a TypeScript error on compile (if using TypeScript).

import { attr, makeModel, readOnly, toDate } from 'foscia/core';

export default class User extends makeModel('users', {
verifiedAt: readOnly(attr(toDate())),
}) {}

const user = new User();
// TS error on compile and error on runtime:
user.verifiedAt = new Date();

Custom properties

In addition to IDs, attributes and relations, you can implement additional properties to your model. It's useful when you need computed values (getters) or specific instance methods.

This can be done using the definition or an extending class:

// Directly in the definition.
export default makeModel('users', {
firstName: attr(),
lastName: attr(),
get fullName() {
return `${this.firstName} ${this.lastName}`
}
});

// Inside an extending class.
export default class User extends makeModel('users', {
firstName: attr(),
lastName: attr(),
}) {
get fullName() {
return `${this.firstName} ${this.lastName}`
}
}

Hooks

You can hook multiple events from your model instances using the hook registration functions:

  • onRetrieved: instance was deserialized from a backend response.
  • onCreating: action to create instance will run soon.
  • onCreated: action to create instance was ran successfully.
  • onUpdating: action to update instance will run soon.
  • onUpdated: action to update instance was ran successfully.
  • onSaving: action to save (create or update) instance will run soon (always ran after onCreating and onUpdating).
  • onSaved: action to save (create or update) instance was ran successfully (always ran after onCreated and onUpdated).
  • onDestroying: action to destroy instance will run soon.
  • onDestroyed: action to destroy instance was ran successfully.

To register a hook callback, you must pass a model class and a callback function to the registration function. It will return a function that you can call to unregister the hook. All model hooks' callbacks have the concerned model instance as the only provided argument.

import { onSaving } from 'foscia/core';

// After this, the hook will run on each User instance saving.
const unregisterThisHook = onSaving(User, async (user) => {
// TODO Do something (a)sync with user instance before saving.
});

// After this, this hook will never run again.
unregisterThisHook();
info

Hooks' callbacks are async and executed in a sequential fashion (one by one, not parallelized).

You can temporally disable hook execution for a given model by using the withoutHooks function.

Be aware that withoutHooks will always return a promise, even when your callback is a sync function.

import { withoutHooks } from 'foscia/core';

const asyncResultOfYourCallback = await withoutHooks(User, async () => {
// TODO Do something async and return it.
});