Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

All notable changes to this project will be documented in this file.

## [1.6.2] - 2026-07-17

### Fixed

- **`{ type: undefined }` and `{ type: null }` crash**: `normalizeConf` previously accepted config objects where `type` was `undefined` or `null` (because `'type' in conf` is `true`), causing a raw `TypeError` on the downstream `.prototype` access. Now throws a structured `SchemaDefinitionError` with code `SCHEMA_DEFINITION_ERROR`.
- **Null-prototype object crash**: Values created via `Object.create(null)` or exotic deserialization (no `.constructor`) caused a raw `TypeError` in `isOfType()` and the error message formatter. Now throws a structured `TypeValidationError` with message `"...got null-prototype object"`.
- **`Union(Array, String)` silent char-split**: A string value passed to a `Union(Array, String)` field would silently enter the Array branch and iterate characters into `['h','e','l','l','o']`. The structural branch (Array/Set) now checks the runtime value's actual type, not just the schema declaration.
- **`isOfType()` overly permissive `!isNaN` check**: The `(conf.type === Date || conf.type === Number) && !isNaN(value)` fallback allowed strings like `"42"` to pass type-checking for `Number` fields without coercion, producing a string where a number was expected. Removed; values must match the declared type or go through explicit `coerce: true`.
- **Missing `static schema` silent no-op**: A subclass with no `static schema` defined would silently construct empty objects. Now throws `SchemaDefinitionError` with message `"ClassName has no schema defined"`.

### Added

- **8 regression tests**: Covering `{ type: undefined }`, `{ type: null }`, null-prototype objects, `Union(Array, String)` with string/array inputs, `Number` strict type rejection, `Number` coercion interop, and missing schema detection.

## [1.6.0] - 2026-07-12

### Added
Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,20 +448,35 @@ 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 |
| `TypeValidationError` | Value constructor doesn't match schema type, or value has no prototype chain |
| `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 |
| `SchemaDefinitionError` | Malformed schema definition, `{ type: undefined }`, `{ type: null }`, or missing `static schema` |
| `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.

---

## Defensive guarantees

ModelCore guards against several degenerate inputs that would otherwise cause raw `TypeError` crashes or silent data corruption:

| Input | Before | After |
|---|---|---|
| `{ type: undefined }` in schema | Raw `TypeError: Cannot read properties of undefined` | `SchemaDefinitionError` with path and code |
| `{ type: null }` in schema | Raw `TypeError` | `SchemaDefinitionError` with path and code |
| `Object.create(null)` as field value | Raw `TypeError: Cannot read properties of undefined (reading 'name')` | `TypeValidationError` with message `"...got null-prototype object"` |
| `Union(Array, String)` with string input | String silently split into char array `['h','e','l','l','o']` | String preserved as-is |
| `"42"` for a `Number` field (no `coerce: true`) | Silently accepted as string | `TypeValidationError` — use `coerce: true` to auto-convert |
| Subclass with no `static schema` | Silent empty object | `SchemaDefinitionError` with class name |



## Performance

ModelCore achieves **>380K ops/sec** across all operations on 100K iterations (Node 24) on a regular laptop:
Expand Down
31 changes: 22 additions & 9 deletions base.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,11 @@ export function buildError(errorType, message, source, path, expected, received,
function normalizeConf(conf, path) {
if (typeof conf === "function")
return { type: conf };
if (conf && typeof conf === "object" && 'type' in conf)
if (conf && typeof conf === "object" && 'type' in conf) {
if (typeof conf.type !== "function")
throw buildError(SchemaDefinitionError, `Schema field '${path}' type must be a constructor function`, Base, path, Function, conf.type, "SCHEMA_DEFINITION_ERROR");
return conf;
}
throw buildError(SchemaDefinitionError, `Invalid schema definition for '${path}'`, Base, path, Object, conf, "SCHEMA_DEFINITION_ERROR");
}
// ==================== PROXY HANDLER ====================
Expand Down Expand Up @@ -345,6 +348,8 @@ export default class Base {
json() { return JSON.stringify(this.toObject()); }
setProperties(schema, data, isNew = false) {
const ctor = this.constructor;
if (!schema || typeof schema !== 'object')
throw buildError(SchemaDefinitionError, `${ctor.name} has no schema defined`, ctor.name, "", Object, schema, "SCHEMA_DEFINITION_ERROR");
if (ctor.immutable && !isNew)
throw buildError(ImmutableObjectError, `Cannot update immutable object of type ${ctor.name}`, ctor.name, "", null, data, "IMMUTABLE_CLASS_UPDATE");
for (const key in schema) {
Expand All @@ -363,14 +368,20 @@ export default class Base {
const { conf, value } = this.validateType(confPassed, valuePassed, path);
let toReturn;
const unionTypes = conf.type === ModelCoreUnion ? new conf.type() : conf.type.prototype instanceof ModelCoreUnion ? new conf.type() : null;
const isArray = conf.type === Array ||
const schemaHasArray = conf.type === Array ||
(unionTypes && unionTypes.some((t) => t === Array || t.prototype instanceof Array))
|| (!unionTypes && conf.type.prototype instanceof Array);
const isSet = !isArray && (conf.type === Set ||
const schemaHasSet = !schemaHasArray && (conf.type === Set ||
(unionTypes && unionTypes.some((t) => t === Set || t.prototype instanceof Set))
|| (!unionTypes && conf.type.prototype instanceof Set));
const isObject = !isArray && !isSet && (conf.type === Object || (unionTypes && unionTypes.some((t) => t === Object)));
const isMap = !isArray && !isSet && !isObject && (conf.type === Map || (unionTypes && unionTypes.some((t) => t === Map || t.prototype instanceof Map)));
const schemaHasObject = !schemaHasArray && !schemaHasSet && (conf.type === Object || (unionTypes && unionTypes.some((t) => t === Object)));
const schemaHasMap = !schemaHasArray && !schemaHasSet && !schemaHasObject && (conf.type === Map || (unionTypes && unionTypes.some((t) => t === Map || t.prototype instanceof Map)));
// For unions, structural branches (Array/Set/Object/Map) only apply when the runtime value actually matches that type.
// Without this guard, e.g. Union(Array/Object, String) with a string value would enter the structural branch and crash/fail.
const isArray = schemaHasArray && (Array.isArray(value) || value instanceof Array);
const isSet = schemaHasSet && (value instanceof Set);
const isObject = schemaHasObject && (typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Set) && !(value instanceof Map));
const isMap = schemaHasMap && (value instanceof Map);
if (isArray || isSet) {
if (!conf.values)
throw buildError(SchemaDefinitionError, `Missing array value configuration at ${path}`, this.runValidate, path, Object, value, "SCHEMA_DEFINITION_ERROR");
Expand Down Expand Up @@ -464,10 +475,12 @@ export default class Base {
return { conf, value };
}
}
// Guard against null-prototype objects (e.g. Object.create(null)) which have no .constructor
if (value != null && !value.constructor)
throw buildError(TypeValidationError, `Invalid type at '${path}', expected ${conf.type.name}, got null-prototype object`, this.constructor.name, path, conf.type, value, "INVALID_TYPE");
const isOfType = () => {
return (unionTypes && unionTypes.some((t) => value.constructor === t)) ||
value.constructor === conf.type ||
((conf.type === Date || conf.type === Number) && !isNaN(value));
const ctor = value.constructor;
return (unionTypes && unionTypes.some((t) => ctor === t)) || ctor === conf.type;
};
if (!isOfType()) {
// Attempt to coerce the value to the correct type if possible. Valuable for date strings from a json for example
Expand All @@ -485,7 +498,7 @@ export default class Base {
}
})();
if (!isOfType())
throw buildError(TypeValidationError, `Invalid type at '${path}', expected ${conf.type.name}, got ${value.constructor.name}`, this.constructor.name, path, conf.type, value, "INVALID_TYPE");
throw buildError(TypeValidationError, `Invalid type at '${path}', expected ${conf.type.name}, got ${value?.constructor?.name ?? typeof value}`, this.constructor.name, path, conf.type, value, "INVALID_TYPE");
}
if ((conf.max !== undefined) && (value.length ? value.length > conf.max : value > conf.max))
throw buildError(RangeError, `Value too large for '${path}', maximum: ${conf.max}`, this.validateType, path, conf.max, value, "VALUE_TOO_LARGE");
Expand Down
34 changes: 25 additions & 9 deletions base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,11 @@ export function buildError(

function normalizeConf(conf: FieldConfig | any, path: string): FieldConfig {
if (typeof conf === "function") return { type: conf } as FieldConfig;
if (conf && typeof conf === "object" && 'type' in conf) return conf as FieldConfig;
if (conf && typeof conf === "object" && 'type' in conf) {
if (typeof conf.type !== "function")
throw buildError(SchemaDefinitionError, `Schema field '${path}' type must be a constructor function`, Base, path, Function, conf.type, "SCHEMA_DEFINITION_ERROR");
return conf as FieldConfig;
}
throw buildError(SchemaDefinitionError, `Invalid schema definition for '${path}'`, Base, path, Object, conf, "SCHEMA_DEFINITION_ERROR");
}

Expand Down Expand Up @@ -467,6 +471,8 @@ export default class Base {

private setProperties(schema: SchemaDefinition, data: Record<string, any>, isNew: boolean = false): void {
const ctor = this.constructor as typeof Base & BaseConstructor;
if (!schema || typeof schema !== 'object')
throw buildError(SchemaDefinitionError, `${ctor.name} has no schema defined`, ctor.name, "", Object, schema, "SCHEMA_DEFINITION_ERROR");
if (ctor.immutable && !isNew)
throw buildError(
ImmutableObjectError, `Cannot update immutable object of type ${ctor.name}`,
Expand All @@ -488,17 +494,24 @@ export default class Base {
let toReturn: typeof conf.type;
const unionTypes = conf.type === ModelCoreUnion ? new conf.type() : conf.type.prototype instanceof ModelCoreUnion ? new conf.type() : null;

const isArray = conf.type === Array ||
const schemaHasArray = conf.type === Array ||
(unionTypes && unionTypes.some((t: any) => t === Array || t.prototype instanceof Array))
|| (!unionTypes && conf.type.prototype instanceof Array)

const isSet = !isArray && (conf.type === Set ||
const schemaHasSet = !schemaHasArray && (conf.type === Set ||
(unionTypes && unionTypes.some((t: any) => t === Set || t.prototype instanceof Set))
|| (!unionTypes && conf.type.prototype instanceof Set))

const isObject = !isArray && !isSet && (conf.type === Object || (unionTypes && unionTypes.some((t: any) => t === Object)));
const schemaHasObject = !schemaHasArray && !schemaHasSet && (conf.type === Object || (unionTypes && unionTypes.some((t: any) => t === Object)));

const schemaHasMap = !schemaHasArray && !schemaHasSet && !schemaHasObject && (conf.type === Map || (unionTypes && unionTypes.some((t: any) => t === Map || t.prototype instanceof Map)));

const isMap = !isArray && !isSet && !isObject && (conf.type === Map || (unionTypes && unionTypes.some((t: any) => t === Map || t.prototype instanceof Map)))
// For unions, structural branches (Array/Set/Object/Map) only apply when the runtime value actually matches that type.
// Without this guard, e.g. Union(Array/Object, String) with a string value would enter the structural branch and crash/fail.
const isArray = schemaHasArray && (Array.isArray(value) || value instanceof Array);
const isSet = schemaHasSet && (value instanceof Set);
const isObject = schemaHasObject && (typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Set) && !(value instanceof Map));
const isMap = schemaHasMap && (value instanceof Map);

if (isArray || isSet) {
if (!conf.values) throw buildError(
Expand Down Expand Up @@ -590,10 +603,13 @@ export default class Base {
}
}

// Guard against null-prototype objects (e.g. Object.create(null)) which have no .constructor
if (value != null && !value.constructor)
throw buildError(TypeValidationError, `Invalid type at '${path}', expected ${conf.type.name}, got null-prototype object`, this.constructor.name, path, conf.type, value, "INVALID_TYPE");
Comment on lines +606 to +608

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with copilot on this one. That also means checking of the prototype is unnecessary. The object will be invalid as long as it doesn't have the necessary properties that your schema expects.


const isOfType = () => {
return (unionTypes && unionTypes.some((t: any) => value.constructor === t)) ||
value.constructor === conf.type ||
((conf.type === Date || conf.type === Number) && !isNaN(value))
const ctor = value.constructor;
return (unionTypes && unionTypes.some((t: any) => ctor === t)) || ctor === conf.type
};

if (!isOfType()) {
Expand All @@ -611,7 +627,7 @@ export default class Base {
})();

if (!isOfType())
throw buildError(TypeValidationError, `Invalid type at '${path}', expected ${conf.type.name}, got ${value.constructor.name}`, this.constructor.name, path, conf.type, value, "INVALID_TYPE");
throw buildError(TypeValidationError, `Invalid type at '${path}', expected ${conf.type.name}, got ${value?.constructor?.name ?? typeof value}`, this.constructor.name, path, conf.type, value, "INVALID_TYPE");
}

if ((conf.max !== undefined) && (value.length ? value.length > conf.max : value > conf.max)) throw buildError(RangeError, `Value too large for '${path}', maximum: ${conf.max}`, this.validateType, path, conf.max, value, "VALUE_TOO_LARGE");
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bufferpunk/modelcore",
"version": "1.6.1",
"version": "1.6.2",
"description": "A blazing fast, lightweight and Reactive Class-Based Object Modeling and data integrity framework",
"type": "module",
"main": "base.js",
Expand Down
148 changes: 147 additions & 1 deletion test/base.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import Base, { Union, buildError, ValueError } from "../base.ts";
import Base, { Union, buildError, ValueError, SchemaDefinitionError, TypeValidationError } from "../base.ts";

/* Tests to verify core functionality. You can add more as needed. Send a PR to be included. */

Expand Down Expand Up @@ -836,3 +836,149 @@ test("error.expected contains meaningful values (type, min, max, enum)", () => {
assert.ok(e.expected != null);
}
});

// ==================== NULL & EDGE-CASE GUARDS ====================

test("schema field with { type: undefined } throws SchemaDefinitionError", () => {
class U extends Base {
static schema = { name: { type: undefined } };
}

assert.throws(
() => new U({ name: "hello" }),
(err) => {
assert.ok(err instanceof SchemaDefinitionError);
assert.match(err.message, /must be a constructor function/);
assert.equal(err.code, "SCHEMA_DEFINITION_ERROR");
return true;
}
);
});

test("schema field with { type: null } throws SchemaDefinitionError", () => {
class U extends Base {
static schema = { name: { type: null } };
}

assert.throws(
() => new U({ name: "hello" }),
(err) => {
assert.ok(err instanceof SchemaDefinitionError);
assert.match(err.message, /must be a constructor function/);
return true;
}
);
});

test("schema field with non-function type (e.g. string) throws SchemaDefinitionError", () => {
class U extends Base {
static schema = { name: { type: "string" } };
}

assert.throws(
() => new U({ name: "hello" }),
(err) => {
assert.ok(err instanceof SchemaDefinitionError);
assert.match(err.message, /must be a constructor function/);
return true;
}
);
});

test("null-prototype object throws TypeValidationError instead of raw TypeError", () => {
class U extends Base {
static schema = { info: String };
}

const nullObj = Object.create(null);
assert.throws(
() => new U({ info: nullObj }),
(err) => {
assert.ok(err instanceof TypeValidationError);
assert.match(err.message, /null-prototype object/);
assert.equal(err.code, "INVALID_TYPE");
return true;
}
);
});

test("Union(Array, String) with string value preserves string (no char-split)", () => {
class U extends Base {
static schema = {
val: { type: Union(Array, String), values: String }
};
}

const u = new U({ val: "hello" });
assert.equal(typeof u.val, "string");
assert.equal(u.val, "hello");
assert.equal(Array.isArray(u.val), false);
});

test("Union(Array, String) with array value still validates as array", () => {
class U extends Base {
static schema = {
val: { type: Union(Array, String), values: String }
};
}

const u = new U({ val: ["a", "b"] });
assert.ok(Array.isArray(u.val));
assert.deepEqual(Array.from(u.val), ["a", "b"]);
});

test("Number field rejects string without coerce flag", () => {
class U extends Base {
static schema = { age: { type: Number } };
}

assert.throws(
() => new U({ age: "42" }),
(err) => {
assert.ok(err instanceof TypeValidationError);
assert.equal(err.code, "INVALID_TYPE");
return true;
}
);
});

test("Number field with coerce: true converts string to number", () => {
class U extends Base {
static schema = { age: { type: Number, coerce: true } };
}

const u = new U({ age: "42" });
assert.ok(u.age instanceof Number || typeof u.age === "number");
});

test("subclass with no static schema throws SchemaDefinitionError", () => {
class NoSchema extends Base {}

assert.throws(
() => new NoSchema({}),
(err) => {
assert.ok(err instanceof SchemaDefinitionError);
assert.match(err.message, /has no schema defined/);
assert.equal(err.code, "SCHEMA_DEFINITION_ERROR");
return true;
}
);
});

test("Union(Object, String) with string value preserves string (does not run Object keys validations)", () => {
class U extends Base {
static schema = {
val: {
type: Union(Object, String),
keys: {
prop: { type: String, required: true }
}
}
};
}

const u = new U({ val: "hello" });
assert.equal(typeof u.val, "string");
assert.equal(u.val, "hello");
});