From 4c8ddd2b4bdf183f7b9e1cef9f243766b802cd16 Mon Sep 17 00:00:00 2001 From: Iradukunda Fils Date: Fri, 17 Jul 2026 19:42:39 +0100 Subject: [PATCH 1/3] fix: resolve critical null-pointer and semantic union vulnerabilities in validation pipeline --- CHANGELOG.md | 14 ++++++ README.md | 19 +++++++- base.d.ts | 84 ++++++++++++++++++++++++++++++++- base.js | 25 +++++++--- base.ts | 28 ++++++++--- test/base.test.js | 115 +++++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 267 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ba02e1..f4219fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index cd03298..af536cb 100644 --- a/README.md +++ b/README.md @@ -448,13 +448,13 @@ 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()`) | @@ -462,6 +462,21 @@ All errors extend `ModelCoreError`, which carries `source`, `path`, `expected`, --- +## 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: diff --git a/base.d.ts b/base.d.ts index e1d60cd..f7ef96b 100644 --- a/base.d.ts +++ b/base.d.ts @@ -76,7 +76,89 @@ export declare class ValueError extends ModelCoreError { static errorName: string; } export declare function Union any)[]>(...args: T): { - new (): InstanceType; + new (): { + [n: number]: any; + keys(): ArrayIterator; + length: number; + toString(): string; + toLocaleString(): string; + toLocaleString(locales: string | string[], options?: Intl.NumberFormatOptions & Intl.DateTimeFormatOptions): string; + pop(): any; + push(...items: any[]): number; + concat(...items: ConcatArray[]): any[]; + concat(...items: any[]): any[]; + join(separator?: string): string; + reverse(): any[]; + shift(): any; + slice(start?: number, end?: number): any[]; + sort(compareFn?: ((a: any, b: any) => number) | undefined): /*elided*/ any; + splice(start: number, deleteCount?: number): any[]; + splice(start: number, deleteCount: number, ...items: any[]): any[]; + unshift(...items: any[]): number; + indexOf(searchElement: any, fromIndex?: number): number; + lastIndexOf(searchElement: any, fromIndex?: number): number; + every(predicate: (value: any, index: number, array: any[]) => value is S, thisArg?: any): this is S[]; + every(predicate: (value: any, index: number, array: any[]) => unknown, thisArg?: any): boolean; + some(predicate: (value: any, index: number, array: any[]) => unknown, thisArg?: any): boolean; + forEach(callbackfn: (value: any, index: number, array: any[]) => void, thisArg?: any): void; + map(callbackfn: (value: any, index: number, array: any[]) => U, thisArg?: any): U[]; + filter(predicate: (value: any, index: number, array: any[]) => value is S, thisArg?: any): S[]; + filter(predicate: (value: any, index: number, array: any[]) => unknown, thisArg?: any): any[]; + reduce(callbackfn: (previousValue: any, currentValue: any, currentIndex: number, array: any[]) => any): any; + reduce(callbackfn: (previousValue: any, currentValue: any, currentIndex: number, array: any[]) => any, initialValue: any): any; + reduce(callbackfn: (previousValue: U, currentValue: any, currentIndex: number, array: any[]) => U, initialValue: U): U; + reduceRight(callbackfn: (previousValue: any, currentValue: any, currentIndex: number, array: any[]) => any): any; + reduceRight(callbackfn: (previousValue: any, currentValue: any, currentIndex: number, array: any[]) => any, initialValue: any): any; + reduceRight(callbackfn: (previousValue: U, currentValue: any, currentIndex: number, array: any[]) => U, initialValue: U): U; + find(predicate: (value: any, index: number, obj: any[]) => value is S, thisArg?: any): S | undefined; + find(predicate: (value: any, index: number, obj: any[]) => unknown, thisArg?: any): any; + findIndex(predicate: (value: any, index: number, obj: any[]) => unknown, thisArg?: any): number; + fill(value: any, start?: number, end?: number): /*elided*/ any; + copyWithin(target: number, start: number, end?: number): /*elided*/ any; + [Symbol.iterator](): ArrayIterator; + entries(): ArrayIterator<[number, any]>; + values(): ArrayIterator; + readonly [Symbol.unscopables]: { + [x: number]: boolean | undefined; + length?: boolean | undefined; + toString?: boolean | undefined; + toLocaleString?: boolean | undefined; + pop?: boolean | undefined; + push?: boolean | undefined; + concat?: boolean | undefined; + join?: boolean | undefined; + reverse?: boolean | undefined; + shift?: boolean | undefined; + slice?: boolean | undefined; + sort?: boolean | undefined; + splice?: boolean | undefined; + unshift?: boolean | undefined; + indexOf?: boolean | undefined; + lastIndexOf?: boolean | undefined; + every?: boolean | undefined; + some?: boolean | undefined; + forEach?: boolean | undefined; + map?: boolean | undefined; + filter?: boolean | undefined; + reduce?: boolean | undefined; + reduceRight?: boolean | undefined; + find?: boolean | undefined; + findIndex?: boolean | undefined; + fill?: boolean | undefined; + copyWithin?: boolean | undefined; + [Symbol.iterator]?: boolean | undefined; + entries?: boolean | undefined; + keys?: boolean | undefined; + values?: boolean | undefined; + readonly [Symbol.unscopables]?: boolean | undefined; + includes?: boolean | undefined; + flatMap?: boolean | undefined; + flat?: boolean | undefined; + }; + includes(searchElement: any, fromIndex?: number): boolean; + flatMap(callback: (this: This, value: any, index: number, array: any[]) => U | readonly U[], thisArg?: This | undefined): U[]; + flat(this: A, depth?: D | undefined): FlatArray[]; + }; unionTypes: T; isArray(arg: any): arg is any[]; from(arrayLike: ArrayLike): T_1[]; diff --git a/base.js b/base.js index 71f5253..2ab8a19 100644 --- a/base.js +++ b/base.js @@ -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 (isNone(conf.type)) + throw buildError(SchemaDefinitionError, `Schema field '${path}' has no type defined`, 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 ==================== @@ -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) { @@ -363,12 +368,16 @@ 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)); + // 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, String) with a string value would enter the Array branch and iterate chars. + const isArray = schemaHasArray && (Array.isArray(value) || value instanceof Array); + const isSet = schemaHasSet && (value 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))); if (isArray || isSet) { @@ -464,10 +473,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 @@ -485,7 +496,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"); diff --git a/base.ts b/base.ts index 04351b7..2059402 100644 --- a/base.ts +++ b/base.ts @@ -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 (isNone(conf.type)) + throw buildError(SchemaDefinitionError, `Schema field '${path}' has no type defined`, 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"); } @@ -467,6 +471,8 @@ export default class Base { private setProperties(schema: SchemaDefinition, data: Record, 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}`, @@ -488,14 +494,19 @@ 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)) + // 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, String) with a string value would enter the Array branch and iterate chars. + const isArray = schemaHasArray && (Array.isArray(value) || value instanceof Array); + const isSet = schemaHasSet && (value instanceof Set); + const isObject = !isArray && !isSet && (conf.type === Object || (unionTypes && unionTypes.some((t: any) => t === Object))); const isMap = !isArray && !isSet && !isObject && (conf.type === Map || (unionTypes && unionTypes.some((t: any) => t === Map || t.prototype instanceof Map))) @@ -590,10 +601,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"); + 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()) { @@ -611,7 +625,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"); diff --git a/test/base.test.js b/test/base.test.js index c7d32f5..2573b02 100644 --- a/test/base.test.js +++ b/test/base.test.js @@ -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. */ @@ -836,3 +836,116 @@ 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, /has no type defined/); + 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, /has no type defined/); + 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; + } + ); +}); From 08d6ce6b6d220b3ad1e04405e09aad352e76c6cb Mon Sep 17 00:00:00 2001 From: Iradukunda Fils Date: Fri, 17 Jul 2026 22:44:07 +0100 Subject: [PATCH 2/3] fix(validation): enforce strict runtime checks for object/map unions and require constructor function type --- base.d.ts | 84 +---------------------------------------------- base.js | 12 ++++--- base.ts | 16 +++++---- package-lock.json | 4 +-- test/base.test.js | 37 +++++++++++++++++++-- 5 files changed, 54 insertions(+), 99 deletions(-) diff --git a/base.d.ts b/base.d.ts index f7ef96b..e1d60cd 100644 --- a/base.d.ts +++ b/base.d.ts @@ -76,89 +76,7 @@ export declare class ValueError extends ModelCoreError { static errorName: string; } export declare function Union any)[]>(...args: T): { - new (): { - [n: number]: any; - keys(): ArrayIterator; - length: number; - toString(): string; - toLocaleString(): string; - toLocaleString(locales: string | string[], options?: Intl.NumberFormatOptions & Intl.DateTimeFormatOptions): string; - pop(): any; - push(...items: any[]): number; - concat(...items: ConcatArray[]): any[]; - concat(...items: any[]): any[]; - join(separator?: string): string; - reverse(): any[]; - shift(): any; - slice(start?: number, end?: number): any[]; - sort(compareFn?: ((a: any, b: any) => number) | undefined): /*elided*/ any; - splice(start: number, deleteCount?: number): any[]; - splice(start: number, deleteCount: number, ...items: any[]): any[]; - unshift(...items: any[]): number; - indexOf(searchElement: any, fromIndex?: number): number; - lastIndexOf(searchElement: any, fromIndex?: number): number; - every(predicate: (value: any, index: number, array: any[]) => value is S, thisArg?: any): this is S[]; - every(predicate: (value: any, index: number, array: any[]) => unknown, thisArg?: any): boolean; - some(predicate: (value: any, index: number, array: any[]) => unknown, thisArg?: any): boolean; - forEach(callbackfn: (value: any, index: number, array: any[]) => void, thisArg?: any): void; - map(callbackfn: (value: any, index: number, array: any[]) => U, thisArg?: any): U[]; - filter(predicate: (value: any, index: number, array: any[]) => value is S, thisArg?: any): S[]; - filter(predicate: (value: any, index: number, array: any[]) => unknown, thisArg?: any): any[]; - reduce(callbackfn: (previousValue: any, currentValue: any, currentIndex: number, array: any[]) => any): any; - reduce(callbackfn: (previousValue: any, currentValue: any, currentIndex: number, array: any[]) => any, initialValue: any): any; - reduce(callbackfn: (previousValue: U, currentValue: any, currentIndex: number, array: any[]) => U, initialValue: U): U; - reduceRight(callbackfn: (previousValue: any, currentValue: any, currentIndex: number, array: any[]) => any): any; - reduceRight(callbackfn: (previousValue: any, currentValue: any, currentIndex: number, array: any[]) => any, initialValue: any): any; - reduceRight(callbackfn: (previousValue: U, currentValue: any, currentIndex: number, array: any[]) => U, initialValue: U): U; - find(predicate: (value: any, index: number, obj: any[]) => value is S, thisArg?: any): S | undefined; - find(predicate: (value: any, index: number, obj: any[]) => unknown, thisArg?: any): any; - findIndex(predicate: (value: any, index: number, obj: any[]) => unknown, thisArg?: any): number; - fill(value: any, start?: number, end?: number): /*elided*/ any; - copyWithin(target: number, start: number, end?: number): /*elided*/ any; - [Symbol.iterator](): ArrayIterator; - entries(): ArrayIterator<[number, any]>; - values(): ArrayIterator; - readonly [Symbol.unscopables]: { - [x: number]: boolean | undefined; - length?: boolean | undefined; - toString?: boolean | undefined; - toLocaleString?: boolean | undefined; - pop?: boolean | undefined; - push?: boolean | undefined; - concat?: boolean | undefined; - join?: boolean | undefined; - reverse?: boolean | undefined; - shift?: boolean | undefined; - slice?: boolean | undefined; - sort?: boolean | undefined; - splice?: boolean | undefined; - unshift?: boolean | undefined; - indexOf?: boolean | undefined; - lastIndexOf?: boolean | undefined; - every?: boolean | undefined; - some?: boolean | undefined; - forEach?: boolean | undefined; - map?: boolean | undefined; - filter?: boolean | undefined; - reduce?: boolean | undefined; - reduceRight?: boolean | undefined; - find?: boolean | undefined; - findIndex?: boolean | undefined; - fill?: boolean | undefined; - copyWithin?: boolean | undefined; - [Symbol.iterator]?: boolean | undefined; - entries?: boolean | undefined; - keys?: boolean | undefined; - values?: boolean | undefined; - readonly [Symbol.unscopables]?: boolean | undefined; - includes?: boolean | undefined; - flatMap?: boolean | undefined; - flat?: boolean | undefined; - }; - includes(searchElement: any, fromIndex?: number): boolean; - flatMap(callback: (this: This, value: any, index: number, array: any[]) => U | readonly U[], thisArg?: This | undefined): U[]; - flat(this: A, depth?: D | undefined): FlatArray[]; - }; + new (): InstanceType; unionTypes: T; isArray(arg: any): arg is any[]; from(arrayLike: ArrayLike): T_1[]; diff --git a/base.js b/base.js index 2ab8a19..e7ee8e9 100644 --- a/base.js +++ b/base.js @@ -79,8 +79,8 @@ function normalizeConf(conf, path) { if (typeof conf === "function") return { type: conf }; if (conf && typeof conf === "object" && 'type' in conf) { - if (isNone(conf.type)) - throw buildError(SchemaDefinitionError, `Schema field '${path}' has no type defined`, Base, path, Function, conf.type, "SCHEMA_DEFINITION_ERROR"); + 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"); @@ -374,12 +374,14 @@ export default class Base { const schemaHasSet = !schemaHasArray && (conf.type === Set || (unionTypes && unionTypes.some((t) => t === Set || t.prototype instanceof Set)) || (!unionTypes && conf.type.prototype instanceof Set)); + 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, String) with a string value would enter the Array branch and iterate chars. + // 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 = !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 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"); diff --git a/base.ts b/base.ts index 2059402..60e9073 100644 --- a/base.ts +++ b/base.ts @@ -186,8 +186,8 @@ 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) { - if (isNone(conf.type)) - throw buildError(SchemaDefinitionError, `Schema field '${path}' has no type defined`, Base, path, Function, conf.type, "SCHEMA_DEFINITION_ERROR"); + 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"); @@ -502,14 +502,16 @@ export default class Base { (unionTypes && unionTypes.some((t: any) => t === Set || t.prototype instanceof Set)) || (!unionTypes && conf.type.prototype instanceof Set)) + 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))); + // 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, String) with a string value would enter the Array branch and iterate chars. + // 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 = !isArray && !isSet && (conf.type === Object || (unionTypes && unionTypes.some((t: any) => t === Object))); - - const isMap = !isArray && !isSet && !isObject && (conf.type === Map || (unionTypes && unionTypes.some((t: any) => t === Map || t.prototype instanceof Map))) + 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( diff --git a/package-lock.json b/package-lock.json index a255bda..275e6c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bufferpunk/modelcore", - "version": "1.6.0", + "version": "1.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bufferpunk/modelcore", - "version": "1.6.0", + "version": "1.6.1", "license": "MIT", "devDependencies": { "typescript": "^5.0.0" diff --git a/test/base.test.js b/test/base.test.js index 2573b02..8cddd33 100644 --- a/test/base.test.js +++ b/test/base.test.js @@ -848,7 +848,7 @@ test("schema field with { type: undefined } throws SchemaDefinitionError", () => () => new U({ name: "hello" }), (err) => { assert.ok(err instanceof SchemaDefinitionError); - assert.match(err.message, /has no type defined/); + assert.match(err.message, /must be a constructor function/); assert.equal(err.code, "SCHEMA_DEFINITION_ERROR"); return true; } @@ -864,7 +864,22 @@ test("schema field with { type: null } throws SchemaDefinitionError", () => { () => new U({ name: "hello" }), (err) => { assert.ok(err instanceof SchemaDefinitionError); - assert.match(err.message, /has no type defined/); + 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; } ); @@ -949,3 +964,21 @@ test("subclass with no static schema throws SchemaDefinitionError", () => { } ); }); + +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"); +}); + From eba4a7643a7acb724cace0be1637278055980698 Mon Sep 17 00:00:00 2001 From: Iradukunda Fils Date: Fri, 17 Jul 2026 22:59:45 +0100 Subject: [PATCH 3/3] chore: bump version to 1.6.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e179e11..638118b 100644 --- a/package.json +++ b/package.json @@ -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",