npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bjnstnkvc/model

v1.0.0

Published

TypeScript port of Laravel Eloquent's data-modeling layer.

Readme

Model

TypeScript equivalent of the Laravel Eloquent data modeling layer: attributes, casts, accessors and mutators, mass assignment, dirty tracking and serialization, without the database.

Installation & setup

NPM

You can install the package via npm:

npm install @bjnstnkvc/model

and then import it into your project

import { Model, Attribute } from '@bjnstnkvc/model';

Lite

If mass assignment rules and serialization visibility are more than you need, the lite branch carries a simplified variant of the package. It keeps attribute access, casts, accessors and mutators, relationships, defaults, dirty tracking and serialization, and drops fillable, guarded, strict mode, hidden, visible, appends together with the hide, show, append and forceFill methods, so a model is left to do nothing but model data.

It is not published to npm. Install it from the branch, which npm builds during install:

npm install github:BJNSTNKVC/js-model#lite

The API is otherwise the one documented below, so everything except the Mass Assignment section and the visibility rules in Serialization applies there too.

Usage

Defining a Model

To get started, define an interface describing your attributes and extend the Model class with it. The generic is what makes direct property access fully typed:

import { Model, Attribute, type Attributes, type AttributeBag, type Casts } from '@bjnstnkvc/model';

interface UserAttributes {
    id: number;
    first_name: string;
    last_name: string;
    email: string;
    age: number;
    created_at: Date;
    fullName: string;
}

class User extends Model<UserAttributes> {
    /**
     * Get the attributes that should be cast.
     */
    override casts(): Casts<UserAttributes> {
        return {
            id        : 'int',
            age       : 'int',
            created_at: 'datetime',
        };
    }

    /**
     * Get the accessor and mutator definitions.
     */
    override mutators(): Attributes<UserAttributes> {
        return {
            fullName: Attribute.get<string>((value: unknown, attributes: AttributeBag<UserAttributes>): string => `${attributes['first_name']} ${attributes['last_name']}`),
            email   : Attribute.set<string>((value: string): unknown => value.toLowerCase()),
        };
    }

    /**
     * Get the attribute keys that are mass assignable.
     */
    override fillable(): (keyof UserAttributes & string)[] {
        return ['first_name', 'last_name', 'email', 'age'];
    }

    /**
     * Get the attribute keys hidden from serialization.
     */
    override hidden(): (keyof UserAttributes & string)[] {
        return ['email'];
    }

    /**
     * Get the default attribute values.
     */
    override defaults(): Partial<UserAttributes> {
        return { age: 18 };
    }
}

Once defined, attributes may be accessed and assigned as regular properties. Casts, accessors and mutators are applied transparently:

const user: User = User.hydrate({ id: '7', first_name: 'John', last_name: 'Doe', created_at: '2026-08-23T10:00:00.000Z' });

user.id;         // 7
user.fullName;   // 'John Doe'
user.created_at; // Date instance

user.email = '[email protected]';

user.raw('email'); // '[email protected]'

Configuring a Model

Configuration is declared through methods rather than properties, since class field initializers run after the parent constructor. Each method may be overridden per model.

casts()

The casts method returns a map of attributes that should be cast when read. The following cast types are available: int, integer, float, double, number, string, bool, boolean, json, array, object, date, datetime, timestamp and decimal:<places>:

class User extends Model<UserAttributes> {
    /**
     * Get the attributes that should be cast.
     */
    override casts(): Casts<UserAttributes> {
        return {
            age       : 'int',
            options   : 'json',
            salary    : 'decimal:2',
            created_at: 'datetime',
        };
    }
}

An unknown cast type throws a TypeError on both read and write. Values of null and undefined pass through every cast untouched.

mutators()

The mutators method returns the accessor and mutator definitions for the model. See Accessors & Mutators.

fillable()

The fillable method returns the attribute keys that are mass assignable. When the list is empty and no keys are guarded, every attribute is fillable:

class User extends Model<UserAttributes> {
    /**
     * Get the attribute keys that are mass assignable.
     */
    override fillable(): (keyof UserAttributes & string)[] {
        return ['first_name', 'last_name', 'email'];
    }
}

guarded()

The guarded method returns the attribute keys that are not mass assignable. When both lists are declared, the fillable list wins:

class User extends Model<UserAttributes> {
    /**
     * Get the attribute keys that are guarded from mass assignment.
     */
    override guarded(): (keyof UserAttributes & string)[] {
        return ['id'];
    }
}

hidden()

The hidden method returns the attribute keys excluded from serialization:

class User extends Model<UserAttributes> {
    /**
     * Get the attribute keys hidden from serialization.
     */
    override hidden(): (keyof UserAttributes & string)[] {
        return ['email'];
    }
}

visible()

The visible method returns the serialization whitelist. When the list is not empty, only these keys are serialized:

class User extends Model<UserAttributes> {
    /**
     * Get the serialization whitelist.
     */
    override visible(): (keyof UserAttributes & string)[] {
        return ['first_name', 'last_name'];
    }
}

appends()

The appends method returns the virtual keys appended to serialization:

class User extends Model<UserAttributes> {
    /**
     * Get the virtual keys appended to serialization.
     */
    override appends(): (keyof UserAttributes & string)[] {
        return ['fullName'];
    }
}

defaults()

The defaults method returns the initial attribute values. Defaults are applied before construction attributes and do not mark the model as dirty:

class User extends Model<UserAttributes> {
    /**
     * Get the default attribute values.
     */
    override defaults(): Partial<UserAttributes> {
        return { age: 18 };
    }
}

Accessors & Mutators

Accessors and mutators are declared through the Attribute class, mirroring Illuminate\Database\Eloquent\Casts\Attribute.

Attribute.get()

The Attribute.get method defines an accessor, a transformation applied when the attribute is read. The callback receives the raw value and the raw attribute bag:

class User extends Model<UserAttributes> {
    /**
     * Get the accessor and mutator definitions.
     */
    override mutators(): Attributes<UserAttributes> {
        return {
            fullName: Attribute.get<string>((value: unknown, attributes: AttributeBag<UserAttributes>): string => `${attributes['first_name']} ${attributes['last_name']}`),
        };
    }
}

An accessor wins over a cast declared for the same key and receives the raw, uncast value.

Attribute.set()

The Attribute.set method defines a mutator, a transformation applied when the attribute is written. The returned value is stored as the raw attribute:

class User extends Model<UserAttributes> {
    /**
     * Get the accessor and mutator definitions.
     */
    override mutators(): Attributes<UserAttributes> {
        return {
            email: Attribute.set<string>((value: string): unknown => value.toLowerCase()),
        };
    }
}

A mutator returning a plain object writes multiple raw attributes at once. To store a plain object as a single value, wrap it under its own key:

class User extends Model<UserAttributes> {
    /**
     * Get the accessor and mutator definitions.
     */
    override mutators(): Attributes<UserAttributes> {
        return {
            fullName: Attribute.set<string>((value: string): unknown => {
                const [first, last]: string[] = value.split(' ');

                return { first_name: first, last_name: last };
            }),
        };
    }
}

Attribute.make()

The Attribute.make method defines an accessor and a mutator in a single definition:

class User extends Model<UserAttributes> {
    /**
     * Get the accessor and mutator definitions.
     */
    override mutators(): Attributes<UserAttributes> {
        return {
            fullName: Attribute.make<string>({
                get: (value: unknown, attributes: AttributeBag<UserAttributes>): string => `${attributes['first_name']} ${attributes['last_name']}`,
                set: (value: string): unknown => {
                    const [first, last]: string[] = value.split(' ');

                    return { first_name: first, last_name: last };
                },
            }),
        };
    }
}

cache()

The cache method memoizes the accessor result until the attribute is written again. Accessors are otherwise recomputed on every read:

class User extends Model<UserAttributes> {
    /**
     * Get the accessor and mutator definitions.
     */
    override mutators(): Attributes<UserAttributes> {
        return {
            banner: Attribute.get<string>((value: unknown, attributes: AttributeBag<UserAttributes>): string => `Hi ${attributes['name']}`).cache(),
        };
    }
}

Note that a cached accessor reading sibling attributes is only invalidated when its own key is written.

Custom Casts

A custom cast is any class implementing the Cast interface, mirroring Eloquent's CastsAttributes. Pass the class itself in the cast map:

import { Model, type Cast, type AttributeBag, type Casts } from '@bjnstnkvc/model';

class Settings implements Cast<{ theme: string }> {
    /**
     * Freeze the raw settings object on read.
     */
    get(value: unknown, key: string, attributes: AttributeBag): { theme: string } {
        return Object.freeze({ ...(value as { theme: string }) });
    }

    /**
     * Store the settings object as given.
     */
    set(value: { theme: string }, key: string, attributes: AttributeBag): unknown {
        return value;
    }
}

class User extends Model<UserAttributes> {
    /**
     * Get the attributes that should be cast.
     */
    override casts(): Casts<UserAttributes> {
        return { settings: Settings };
    }
}

The class is instantiated once per model and the instance is reused for every read and write of the attribute.

Enum Casting

Enums may be cast by passing the enum itself in the cast map. Values are validated against the enum on both read and write, and an invalid value throws a TypeError. Numeric enums are supported, including their reverse mappings, which are never treated as values:

enum Status {
    Active   = 'active',
    Inactive = 'inactive',
}

class Server extends Model<ServerAttributes> {
    /**
     * Get the attributes that should be cast.
     */
    override casts(): Casts<ServerAttributes> {
        return { status: Status };
    }
}

const server: Server = Server.hydrate({ status: 'active' });

server.status; // Status.Active

server.status = 'archived'; // throws TypeError

Relationships

Since there is no database, a relationship is simply an attribute holding another model or an array of models. Related models are declared through the relations method by passing the model class itself. The cardinality follows the data, so a raw array hydrates into an array of models and a raw object into a single one:

interface UserAttributes {
    name: string;
    profile: Profile;
    posts: Post[];
}

class User extends Model<UserAttributes> {
    /**
     * Get the related model definitions.
     */
    override relations(): Relations<UserAttributes> {
        return {
            profile: Profile,
            posts  : Post,
        };
    }
}

Raw data hydrates into clean model instances on read, while values that are already instances pass through untouched. Related models serialize recursively through toJSON:

const user: User = User.hydrate({ name: 'John', posts: [{ title: 'Hello' }] });

user.posts[0];        // Post instance
JSON.stringify(user); // '{"name":"John","posts":[{"title":"Hello"}]}'

user.posts = [Post.hydrate({ title: 'Manual' })];

Note that a related model is held by reference, so editing it in place is not visible to the parent model's dirty method. Replacing the value is tracked as usual.

Retrieving Attributes

get()

The get method returns an attribute value with accessors and casts applied. Keys outside the declared interface are allowed and returned as unknown:

user.get('age');     // 18
user.get('address'); // unknown key, still readable

raw()

The raw method returns a copy of the raw stored attributes, or a single raw value when a key is given:

user.raw();              // { first_name: 'John', ... }
user.raw('created_at');  // '2026-08-23T10:00:00.000Z'

has()

The has method determines whether an attribute is present, either as a raw value or as a readable virtual:

user.has('first_name'); // true
user.has('fullName');   // true
user.has('missing');    // false

only()

The only method returns a cast applied subset of the attributes:

user.only('first_name', 'age'); // { first_name: 'John', age: 18 }

except()

The except method returns all cast applied attributes except the given keys:

user.except('email');

Setting Attributes

set()

The set method assigns an attribute value, applying mutators and cast normalization. Declared keys are type checked, while any other string key is accepted:

user.set('age', '35');           // stored and read back as 35
user.set('address', 'Main St');  // key outside the interface

fill()

The fill method mass assigns attributes while honoring the fillable and guarded rules. Non fillable keys are silently discarded unless strict mode is enabled:

user.fill({ first_name: 'Jane', id: 1 }); // id is discarded

forceFill()

The forceFill method mass assigns attributes while bypassing all guarding:

user.forceFill({ id: 1 });

forget()

The forget method removes an attribute from raw storage:

user.forget('address');

Mass Assignment

By default every attribute is fillable. Once fillable or guarded lists are declared, offending keys are silently discarded during fill, matching Eloquent. Enabling strict mode throws a MassAssignmentException instead:

Model.strict = true;

new User({ id: 1 }); // throws MassAssignmentException

Model.hydrate()

The static hydrate method creates a model from trusted raw data, bypassing guards and mutators. A hydrated model is synced clean:

const user: User = User.hydrate({ id: 7, first_name: 'John' });

user.dirty(); // false

The method also accepts an array of raw rows, returning an array of models:

const users: User[] = User.hydrate([
    { id: 7, first_name: 'John' },
    { id: 8, first_name: 'Jane' },
]);

Attributes created through the constructor, on the other hand, are marked as dirty:

const user: User = new User({ first_name: 'John' });

user.dirty(); // true

Dirty Tracking

dirty()

The dirty method determines whether any attribute, or any of the given attributes, changed since the last sync:

user.set('first_name', 'Jane');

user.dirty();             // true
user.dirty('first_name'); // true
user.dirty('age');        // false

clean()

The clean method determines whether no attribute, or none of the given attributes, changed since the last sync:

user.set('first_name', 'Jane');

user.clean();             // false
user.clean('first_name'); // false
user.clean('age');        // true

changes()

The changes method returns the raw attributes that changed since the last sync:

user.changes(); // { first_name: 'Jane' }

original()

The original method returns the last synced attributes with casts applied, or a single one of them. An optional fallback is returned when the key was never synced:

user.original('first_name');      // 'John'
user.original('missing', 'none'); // 'none'

sync()

The sync method snapshots the current raw attributes as the original state:

user.sync();

user.dirty(); // false

discard()

The discard method reverts the raw attributes to the last synced original state:

user.set('first_name', 'Jane');
user.discard();

user.first_name; // 'John'

Replicating Models

replicate()

The replicate method copies the model into a fresh, unsaved instance. The replica carries a deep copy of the raw attributes and is marked as dirty. Keys passed to the method are excluded from the copy:

const copy: User = user.replicate('id');

copy.dirty(); // true

Comparing Models

is()

The is method determines whether another model is of the same type with equivalent raw attributes:

const original: User = User.hydrate({ id: 1, first_name: 'John' });
const copy: User = User.hydrate({ id: 1, first_name: 'John' });

original.is(copy);              // true
original.is(new User());        // false
original.is(null);              // false

Serialization

toJSON()

The toJSON method serializes the model to a plain object, applying casts, accessors, appends and visibility rules. Since toJSON is the native serialization hook, JSON.stringify works out of the box:

JSON.stringify(user); // '{"first_name":"John","fullName":"John Doe"}'

hide()

The hide method hides the given keys from serialization at runtime:

user.hide('first_name');

show()

The show method reveals hidden keys at runtime:

user.show('email');

append()

The append method appends the given virtual keys to serialization at runtime:

user.append('fullName');

Notes

Class members shadow same named attributes when accessed as properties. An attribute literally named fill is still stored and remains reachable through user.get('fill') and user.set('fill', value).

Spreading a model or calling Object.keys on it enumerates the attribute keys, with values read through the usual cast and accessor pipeline. Attributes shadowed by a class member are skipped during enumeration, since property access cannot reach them either.