Runtime entity integrity for JavaScript and TypeScript.
Most validation libraries protect the boundary. They check data when it arrives, then step aside.
ModelCore does something different: it stays.
Once you define a schema, the rules travel with the object — through mutations, reassignments, nested updates, and transport. An invalid state can't sneak in after creation. Your entities mean what they say, always.
Pydantic-inspired • Zero dependencies • Frontend & backend
You probably already use Zod, Joi, or Yup. They're great at what they do: validate a plain object at a boundary, then hand it off. But once the object leaves the validator, the rules are gone. The object can drift. It can become invalid silently.
TypeORM and Sequelize give you class-based models, but they're coupled to databases. MobX and Vue reactivity track what changed — but not whether that change should have happened.
ModelCore fills the gap none of them cover:
| Feature | ModelCore | Zod / Joi / Yup | TypeORM / Sequelize | MobX / Vue |
|---|---|---|---|---|
| Class-based models | ✅ | ❌ | ✅ | ❌ |
| Runtime validation | ✅ | ✅ | Limited | ❌ |
| Continuous enforcement | ✅ | ❌ | ❌ | ❌ |
| Immutability enforcement | ✅ | ❌ | Limited | ❌ |
| Automatic coercion | ✅ | Limited | ❌ | ❌ |
| Nested object validation | ✅ | ✅ | Limited | ❌ |
| Zero dependencies | ✅ | ❌ | ❌ | ❌ |
| Frontend & backend | ✅ | ✅ | Backend only | Frontend only |
npm install @bufferpunk/modelcoreimport Base, { SchemaDefinition } from '@bufferpunk/modelcore';
class User extends Base {
static schema = {
name: {
type: String,
min: 2,
max: 80,
beforeChecks: (v: any) => typeof v === 'string' ? v.trim() : v,
afterChecks: (v: any) => v.replace(/\s+/g, ' ')
},
role: {
type: String,
enum: ['admin', 'editor', 'viewer'],
default: 'viewer',
beforeChecks: (v: any) => typeof v === 'string' ? v.toLowerCase() : v
},
confirmed: { type: Boolean, optional: true, default: false }
} as const satisfies SchemaDefinition;
}
const user = new User({ name: ' John Doe ', role: 'EDITOR' });
// → { name: 'John Doe', role: 'editor', confirmed: false }
user.role = 'SUPERUSER'; // throws — 'superuser' is not in enum
user.name = ' Jane '; // coerced and trimmed automaticallyFor the best TypeScript experience, use Model.create() or Model.createFrom() — it infers the instance shape from the schema without any duplication:
const user = User.createFrom({ name: 'Ana Silva', role: 'admin' });
// or
const user = User.create({ name: 'Ana Silva', role: 'admin' });Every assignment and construction runs through this exact sequence:
beforeChecks— sanitize/transform raw input (trim, lowercase, etc.)- Required / optional / default resolution — missing required fields throw; optional fields resolve to
undefined; defaults are applied - Type validation and coercion — checks
value.constructor === conf.type; optionalcoerce: truecalls the constructor min/maxconstraints — applies toString.lengthor numeric valueenumcheck — value must be in the allowed setafterChecks— post-type transformation- Custom
validatehook — arbitrary validation logic (throw to reject) - Validation handlers — registered middleware functions run in order — see Validation handlers
afterChecks— post-type transformation- Custom
validatehook — arbitrary validation logic (throw to reject) - Immutability check — if the field or class is immutable, reject the mutation
interface FieldConfig {
type: Function; // Constructor: String, Number, Boolean, Date, Array, Object, or custom class
immutable?: boolean; // Reject writes after construction
optional?: boolean; // Allow undefined
required?: boolean; // Alias for optional: false
default?: any | (() => any); // Default value (function = factory)
enum?: any[]; // Allowed values
min?: number; // Min length (String) or min value (Number)
max?: number; // Max length (String) or max value (Number)
beforeChecks?: (value: any) => any; // Pre-validation transform
afterChecks?: (value: any) => any; // Post-validation transform
validate?: (value: any) => void; // Custom validation hook
keys?: Record<string, FieldConfig | Function>; // Nested object schema
properties?: Record<string, FieldConfig | Function>; // Alias for keys
values?: FieldConfig | Function; // Array item schema
coerce?: boolean; // Auto-coerce via constructor
[key: string]: any; // Custom properties for validation handlers
}Any field value that is a constructor function (e.g., String, Email) is normalized to { type: thatFunction }. So name: String is equivalent to name: { type: String }.
Mark a class or individual fields as immutable and ModelCore will enforce it at runtime — not just in TypeScript types.
class Order extends Base {
static immutable = true; // entire class is frozen after creation
static schema = {
id: { type: String, immutable: true },
total: { type: Number }
} as const satisfies SchemaDefinition;
}
const order = new Order({ id: 'ord_123', total: 49.99 });
order.total = 99.99; // throws ImmutableObjectErrorImmutable fields lock their first value at construction. Immutable classes block all direct assignment and .update() calls after construction.
class Post extends Base {
static schema = {
title: { type: String, min: 1, max: 200 },
tags: { type: Array, values: { type: String, beforeChecks: v => v.trim() } },
author: {
type: Object,
keys: {
name: String,
email: { type: String, validate: v => { if (!v.includes('@')) throw Error('bad email'); } }
}
}
} as const satisfies SchemaDefinition;
}
const post = new Post({
title: 'Hello',
tags: [' one ', ' two '], // items are trimmed
author: { name: 'Alice', email: 'a@b.com' }
});
post.tags.push(' three '); // validated and trimmed
post.author.name = 'Bob'; // validated via nested proxyNested structures are wrapped in handlers that enforce the same rules. Array mutations (push, unshift, splice, concat, index assignment) are all validated. fill() is forbidden to prevent bulk silent corruption.
Any class can be a field type. ModelCore validates that the value is an instance of the class and runs its constructor logic during coercion.
class Email {
value: string;
constructor(value: string) {
if (!/^\S+@\S+\.\S+$/.test(value)) throw new Error('Invalid email');
this.value = value;
}
}
class User extends Base {
static schema = {
email: Email,
name: String
} as const satisfies SchemaDefinition;
}
const user = new User({ email: 'alice@example.com', name: 'Alice' });
// user.email is an Email instance, validated at constructionRegister reusable validation logic that runs on every field, every model, every time — like a plugin system for the validation pipeline.
import Base, { buildError, ValueError } from '@bufferpunk/modelcore';
// 1. Define a handler that reads custom config properties
function validateRegex(conf: any, value: any, path: string) {
if (conf.regex && typeof value === 'string') {
if (!conf.regex.test(value))
throw buildError(ValueError,
`Value does not match regex ${conf.regex}`,
validateRegex, path, conf.regex, value, "INVALID_REGEX");
}
}
// 2. Register it once — applies everywhere
Base.addValidationHandler(validateRegex);
// 3. Use custom properties in your field config (the index signature allows it)
class User extends Base {
static schema = {
name: {
type: String,
regex: /^[a-zA-Z\s]+$/, // custom property, consumed by validateRegex
beforeChecks: v => v.trim(),
},
nickname: {
type: String,
minWords: 2, // another custom property for a different handler
},
} as const satisfies SchemaDefinition;
}Handler functions receive (FieldConfig, value, path) and are called after built-in checks (type, min/max, enum) and before afterChecks. Throw any ModelCoreError subclass (or a regular Error) to reject the value.
Because FieldConfig includes [key: string]: any, you can attach arbitrary metadata to field definitions without TypeScript errors. This lets handlers carry their own configuration alongside the field:
// Define handlers that read your custom properties
Base.addValidationHandler((conf, value, path) => {
if (conf.minWords && typeof value === 'string') {
const count = value.trim().split(/\s+/).length;
if (count < conf.minWords)
throw buildError(ValueError, `Must have at least ${conf.minWords} words`, ...);
}
});
// Use them in schemas with zero boilerplate
class Article extends Base {
static schema = {
title: { type: String, minWords: 3 },
body: { type: String, minWords: 50 },
summary: { type: String, minWords: 1, optional: true },
} as const satisfies SchemaDefinition;
}To let handlers throw consistent, typed errors:
import { buildError, ValueError } from '@bufferpunk/modelcore';
throw buildError(
ValueError, // Error class (any ModelCoreError subclass)
"Human-readable message",
myHandlerFunction, // source
"field.path", // path
"expected value", // expected
"received value", // received
"CUSTOM_ERROR_CODE" // code
);import Base, { SchemaDefinition, Union } from '@bufferpunk/modelcore';
class User extends Base {
static schema = {
identifier: Union(String, Number),
} as const satisfies SchemaDefinition;
}
const a = new User({ identifier: 'abc' }); // OK
const b = new User({ identifier: 42 }); // OK
const c = new User({ identifier: true }); // throws — not String or NumberUnion accepts any number of constructors (primitives or custom classes) and validates at runtime that the value matches one of them.
const user = new User({ name: 'John', role: 'editor' });
user.name = 'Jane'; // direct assignment, validated
user.update({ name: 'Jane', role: 'admin' }); // bulk update, validatedupdate() performs a batch update — it iterates the schema and validates only the fields present in the data object. Fields not included in the update data are left untouched (no re-validation), making partial updates efficient.
Pass a second argument to the constructor or update() to control behavior:
// Coerce string values into their typed counterparts
class User extends Base {
static schema = {
name: String,
createdAt: { type: Date, coerce: true, required: false },
} as const satisfies SchemaDefinition;
}
// Silently swallow construction errors, marking fields with the error
const user = new User({ name: { not: 'valid' } }, { safe: true });
// user.name === Error (the caught error object)By default, ModelCore throws a RequiredError when a field's schema doesn't mark it as optional and the value is missing. You can relax this globally with Base.autorequire:
// Default behavior — missing non-optional fields throw
class User extends Base {
static schema = { name: { type: String } };
}
new User({}); // throws RequiredError
// Disable autorequire — missing fields silently become undefined
Base.autorequire = false;
new User({}); // { name: undefined }
// Re-enable
Base.autorequire = true; // or delete Base.autorequire| Config | autorequire |
Behavior |
|---|---|---|
optional: true |
any | Allowed, value stays undefined |
required: false |
any | Allowed, value stays undefined |
required: true |
any | Always throws — explicit override |
optional: false |
any | Always throws — explicit override |
| (neither set) | undefined (default) |
Throws RequiredError |
| (neither set) | true |
Throws RequiredError |
| (neither set) | false |
Silently allowed, value stays undefined |
This is useful for gradual adoption — start with autorequire = false while you migrate schemas, then flip it on once all fields are properly annotated.
This can also be used by ORMs, when you query the DB with maybe just an id field, and you want to allow the rest of the fields to be undefined until you fetch them.
ModelCore throws typed error classes for every failure mode:
| Error class | Trigger |
|---|---|
ValidationError |
Custom validate hook throws |
TypeValidationError |
Value constructor doesn't match schema type |
RequiredError |
Required field is missing |
EnumValueError |
Value not in allowed enum set |
RangeError |
Value exceeds min/max |
ImmutableObjectError |
Write to immutable class |
ImmutablePropertyError |
Write to immutable field |
SchemaDefinitionError |
Malformed schema definition |
MissingPropertyError |
Nested required property missing |
ValueError |
Array method misuse (e.g., fill()) |
All errors extend ModelCoreError, which carries source, path, expected, received, and code properties for programmatic handling.
ModelCore achieves >380K ops/sec across all operations on 100K iterations (Node 24) on a regular laptop:
| Operation | Ops/sec |
|---|---|
construct + validate (5 fields incl. nested) |
~383K |
Model.create() factory |
~383K |
batch update validated fields (partial 3/5 fields) |
~399K |
This is comparable to the fastest validation libraries, while also providing continuous enforcement and nested object support. This is enabled by:
Run the included benchmark yourself:
BENCH_ITERATIONS=100000 npm run benchclass Base {
static schema: SchemaDefinition;
static version?: number;
static immutable?: boolean;
static autorequire?: boolean;
static validationHandlers?: Array<Function>;
constructor(obj: Record<string, any>, parseConfig?: parserConfig);
static createFrom<T>(obj, parseConfig?): SchemaToType<T['schema']>;
static create<T>(obj, parseConfig?): SchemaToType<T['schema']>;
static addValidationHandler(handler: Function): void;
update(obj: Record<string, any>, parseConfig?: parserConfig): void;
toObject(): Record<string, any>;
json(): string;
}| Method | Description |
|---|---|
new Model(data, opts?) |
Creates and validates instance. Returns a Proxy. |
Model.createFrom(data, opts?) |
Factory — return type is inferred from schema. |
Model.create(data, opts?) |
Alias for createFrom. |
Base.autorequire |
Global toggle: false allows missing non-optional fields silently (default: undefined = throw). |
Base.addValidationHandler(handler) |
Register a middleware function (conf, value, path) => void run during every validateType call. |
instance.update(data, opts?) |
Batch/Bulk-update validated fields. |
instance.toObject() |
Returns a plain object with all current values. |
instance.json() |
To make things easier for you |