Skip to content

Instantly share code, notes, and snippets.

@fanannan
Created September 4, 2025 02:30
Show Gist options
  • Select an option

  • Save fanannan/ac09d9d423f33e22bf22118347a764d6 to your computer and use it in GitHub Desktop.

Select an option

Save fanannan/ac09d9d423f33e22bf22118347a764d6 to your computer and use it in GitHub Desktop.
Self-contained immutable dictionary with embedded schema
/*!
* schema-model.js
* (c) Gugen Koubou 2025 / MIT License
*
* Self-contained immutable dictionary with embedded schema (Strict & Clojure-inspired)
* Plain JavaScript / Zero dependencies / Single file / UMD / Browser & Node.js
*
* ──────────────────────────────────────────────────────────────────────────────
* Purpose
* - Embeds "hidden schema" in a dictionary for strict validation on all updates
* - Detects unknown keys / Immediate errors on type violations / Consistent req/opt operation
* - No automatic initialization. getIn throws exception on missing keys without default
* - Clojure-style: assocIn/updateIn/dissocIn/mergeIn for immutable updates (non-destructive)
*
* ──────────────────────────────────────────────────────────────────────────────
* Exposed API / 露出 API
* Type/Modifiers (★ defaults specified via constructor args):
* num(dft?) // e.g., num(0)
* str(dft?) // e.g., str("en")
* bool(dft?) // e.g., bool(false)
* obj(dft?) // e.g., obj({}) ※ Only checks for plain object (no internal key validation)
* vec(item, dft?) // e.g., vec(User, [])
* dict(fields, dft?) // e.g., dict({ theme: req(str("light")), ... }, { theme:"light" })
* req(schema) / opt(schema)
*
* Model Creation/Conversion:
* instantiateSchema(schema, [initialPatch]) // Merges defaults + initial patch with strict schema validation
* isModel(value)
* toDict(model) // Pure POJO with hidden metadata removed
*
* Path Utilities:
* makePath() // Safe path generation via template literals
*
* Retrieval:
* getIn(model, path, [defaultValue]) // Throws on missing keys without default
*
* Immutable Updates (dict/array):
* assocIn(model, path, value)
* updateIn(model, path, updaterFn)
* dissocIn(model, path) // Cannot remove required fields
* mergeIn(model, path, patch) // Shallow merge (unknown dict keys forbidden)
* ensureIn(model, path, initialValue) // Sets value only if undefined (explicit initialization)
*
* Array Helpers (STRICT: target must be existing array):
* pushIn, unshiftIn, insertIn, removeAtIn, removeWhereIn, updateAtIn,
* moveIn, swapIn, mapIn, filterIn, sortByIn, uniqByIn,
* toggleIn, setAddIn, setRemoveIn, upsertByIn
*
* Transaction:
* transaction(model, tx => { tx.assocIn(...).updateIn(...).pushIn(...); })
*
* ──────────────────────────────────────────────────────────────────────────────
* Specification Summary / 仕様の要点
* - Schema types: num | str | bool | obj | dict(fields) | vec(item) | any
* ・dict: No additional keys allowed (unknown keys always error). Values validated per field schema
* ・vec : All elements validated against item schema
* ・obj : Only requires "plain object" (no internal key validation)
* ・any : Complete pass-through (escape hatch, use sparingly)
* - req/opt:
* ・req(schema): Required. undefined assignment/deletion causes error. Must be satisfied on creation
* ・opt(schema): Optional. Type-validated if present. May be absent
* ・dict fields default to required unless wrapped in opt()
* - Defaults:
* ・Constructor arg 'dft' stored as node property
* ・instantiateSchema merges schema defaults + initialPatch with full validation
* ・With dft, req can be satisfied without initialPatch. Without dft, must provide in initialPatch
*
* ──────────────────────────────────────────────────────────────────────────────
* Usage Example / 使用例
* const { num, str, bool, obj, dict, vec, any, req, opt,
* instantiateSchema, toDict, makePath,
* getIn, assocIn, updateIn, pushIn, upsertByIn, dissocIn, mergeIn } = SchemaModel;
*
* // 1) Define schema with defaults via constructors
* const User = dict({
* id: req(str()), // No default → required at creation
* name: req(str("Taro")), // Required + default
* note: opt(str()) // Optional
* });
*
* const Settings = dict({
* theme: req(str("light")), // Required + default
* lang: req(str()), // Required (no default → must provide at creation)
* dark: opt(bool(false)), // Optional + default false
* extras: opt(obj({})) // Optional + default {}
* });
*
* const Root = dict({
* user: req(dict({
* settings: req(Settings),
* profile: req(dict({
* age: opt(num()), // Optional
* tags: req(vec(str(), [])) // Required + default []
* }))
* })),
* users: req(vec(User, [])), // Required + default []
* payload: opt(any())
* });
*
* // 2) Create model: lang has no default, must provide in initialPatch
* let state = instantiateSchema(Root, { user: { settings: { lang: 'ja' } } });
*
* // 3) Update (always schema-validated, unknown keys throw error)
* state = assocIn(state, ['user','settings','theme'], 'dark'); // OK
* state = updateIn(state, ['user','profile','tags'], t => t.concat('js')); // OK
* state = pushIn(state, ['users'], { id:'u1', name:'Hanako' }); // OK
* // state = assocIn(state, ['user','settings','lagn'], 'en'); // NG: Unknown key
*
* // 4) Merge (shallow)
* state = mergeIn(state, ['user','settings'], { dark:true });
*
* // 5) Delete (cannot remove required fields)
* // state = dissocIn(state, ['user','settings']); // NG
*
* // 6) Serialize (removes hidden schema)
* const plain = toDict(state);
*/
;(function (global, factory) {
if (typeof module === 'object' && typeof module.exports === 'object') {
module.exports = factory();
} else {
global.SchemaModel = factory();
}
})(typeof window !== 'undefined' ? window : globalThis, function () {
'use strict';
// ───────── Internal Utilities ─────────
const SYM_SCHEMA = Symbol('@@schema');
const FORBIDDEN = Object.freeze({ __proto__:1, constructor:1, prototype:1 });
const hasOwn = (o,k)=>Object.prototype.hasOwnProperty.call(o,k);
const isObj = x => x !== null && typeof x === 'object';
const isPlain= x => Object.prototype.toString.call(x) === '[object Object]';
const cloneShallow = x => Array.isArray(x) ? x.slice() : Object.assign({}, x);
const pstr = keys => keys.join('.');
function normPath(p){
if (Array.isArray(p)) return p.slice();
if (typeof p !== 'string') throw new TypeError('path must be array or string');
const parts = p.replace(/\[(\d+)\]/g, '.$1').split('.').filter(Boolean);
return parts.map(s => String(+s)===s ? +s : s);
}
function assertSafePath(keys){
for (const k of keys) if (typeof k==='string' && FORBIDDEN[k]) throw new Error(`Forbidden path segment: "${k}"`);
}
function assertArrIndex(i,len,ps){
if (!Number.isInteger(i) || i<0 || i>=len) throw new RangeError(`Invalid array index at "${ps}": ${i}`);
}
// ───────── Schema Definitions ─────────
// Node: { kind: 'num'|'str'|'bool'|'obj'|'dict'|'vec'|'any', req?:boolean, dft?:any }
// dict : { kind:'dict', fields: { key: Schema, ... }, req?, dft? }
// vec : { kind:'vec', item: Schema, req?, dft? }
const KINDS = new Set(['num','str','bool','obj','dict','vec','any']);
/**
* Creates an 'any' type schema that accepts any value without validation
* @returns {Object} Schema node with kind='any'
*/
const any = () => ({ kind:'any' }); // Complete pass-through (escape hatch)
/**
* Creates a number type schema
* @param {number} [dft] - Optional default value
* @returns {Object} Schema node with kind='num'
* @throws {TypeError} If default is not a valid number
*/
const num = (dft) => {
const s = { kind:'num' };
if (arguments.length) {
if (typeof dft !== 'number' || Number.isNaN(dft)) throw new TypeError('num(dft): dft must be number');
s.dft = dft;
}
return s;
};
/**
* Creates a string type schema
* @param {string} [dft] - Optional default value
* @returns {Object} Schema node with kind='str'
* @throws {TypeError} If default is not a string
*/
const str = (dft) => {
const s = { kind:'str' };
if (arguments.length) {
if (typeof dft !== 'string') throw new TypeError('str(dft): dft must be string');
s.dft = dft;
}
return s;
};
/**
* Creates a boolean type schema
* @param {boolean} [dft] - Optional default value
* @returns {Object} Schema node with kind='bool'
* @throws {TypeError} If default is not a boolean
*/
const bool = (dft) => {
const s = { kind:'bool' };
if (arguments.length) {
if (typeof dft !== 'boolean') throw new TypeError('bool(dft): dft must be boolean');
s.dft = dft;
}
return s;
};
/**
* Creates a plain object type schema (no internal key validation)
* @param {Object} [dft] - Optional default value
* @returns {Object} Schema node with kind='obj'
* @throws {TypeError} If default is not a plain object
*/
const obj = (dft) => {
const s = { kind:'obj' }; // plain object requirement (no internal key validation)
if (arguments.length) {
if (!isPlain(dft)) throw new TypeError('obj(dft): dft must be plain object');
s.dft = dft;
}
return s;
};
/**
* Creates an array/vector type schema with typed elements
* @param {Object} itemSchema - Schema for array elements
* @param {Array} [dft] - Optional default array value
* @returns {Object} Schema node with kind='vec'
* @throws {TypeError} If itemSchema is invalid or default is not an array
*/
const vec = (itemSchema, dft) => {
if (!isPlain(itemSchema) || !KINDS.has(itemSchema.kind)) throw new TypeError('vec(item): invalid schema');
const s = { kind:'vec', item: itemSchema };
if (arguments.length >= 2) {
if (!Array.isArray(dft)) throw new TypeError('vec(item, dft): dft must be array');
for (let i=0;i<dft.length;i++) validateValue(dft[i], itemSchema, `<vec-dft>[${i}]`);
s.dft = dft;
}
return s;
};
/**
* Creates a dictionary/object type schema with specific field schemas
* @param {Object} fields - Object mapping field names to their schemas
* @param {Object} [dft] - Optional default object value
* @returns {Object} Schema node with kind='dict'
* @throws {TypeError} If fields is not a plain object or contains invalid schemas
* @throws {Error} If forbidden keys are used or unknown keys in default
*/
const dict = (fields, dft) => {
if (!isPlain(fields)) throw new TypeError('dict(fields): fields must be plain object');
const out = {};
for (const k in fields){
if (!hasOwn(fields,k)) continue;
if (FORBIDDEN[k]) throw new Error(`Forbidden key "${k}" in dict schema`);
const sch = fields[k];
if (!isPlain(sch) || !KINDS.has(sch.kind)) throw new TypeError(`dict field "${k}" must be a schema`);
out[k] = sch;
}
const s = { kind:'dict', fields: out };
if (arguments.length >= 2) {
if (!isPlain(dft)) throw new TypeError('dict(fields, dft): dft must be plain object');
for (const k in dft){
if (!hasOwn(dft,k)) continue;
if (!hasOwn(out,k)) throw new Error(`Unknown key "${k}" in dict default`);
validateValue(dft[k], out[k], `<dict-dft>.${k}`);
}
s.dft = dft;
}
return s;
};
/**
* Marks a schema as required
* @param {Object} schema - Schema to mark as required
* @returns {Object} Schema with req=true
*/
const req = (schema) => Object.assign({}, schema, { req:true });
/**
* Marks a schema as optional
* @param {Object} schema - Schema to mark as optional
* @returns {Object} Schema with req=false
*/
const opt = (schema) => Object.assign({}, schema, { req:false });
const isRequired = s => s.req === true;
const hasDefault = s => ('dft' in s);
function assertSchemaNode(s, where){
if (!isPlain(s) || !KINDS.has(s.kind)) throw new TypeError(`Invalid schema at ${where||'<root>'}`);
if (s.kind==='dict'){
for (const k in s.fields){ if (hasOwn(s.fields,k)) assertSchemaNode(s.fields[k], (where?where+'.':'')+k); }
} else if (s.kind==='vec'){
assertSchemaNode(s.item, (where?where:'<vec-item>'));
}
}
function validateValue(v, s, pathStr){
switch(s.kind){
case 'num': if (typeof v!=='number' || Number.isNaN(v)) throw new TypeError(`Expected number at "${pathStr}"`); return;
case 'str': if (typeof v!=='string') throw new TypeError(`Expected string at "${pathStr}"`); return;
case 'bool': if (typeof v!=='boolean') throw new TypeError(`Expected boolean at "${pathStr}"`); return;
case 'obj': if (!isPlain(v)) throw new TypeError(`Expected plain object at "${pathStr}"`); return;
case 'any': return;
case 'dict': {
if (!isPlain(v)) throw new TypeError(`Expected dict (plain object) at "${pathStr}"`);
for (const k in v){
if (!hasOwn(v,k)) continue;
if (FORBIDDEN[k]) throw new Error(`Forbidden key "${k}" at "${pathStr}"`);
const f = s.fields[k];
if (!f) throw new Error(`Unknown key "${k}" at "${pathStr}"`);
validateValue(v[k], f, pathStr ? `${pathStr}.${k}` : k);
}
for (const k in s.fields){
if (!hasOwn(s.fields,k)) continue;
const f = s.fields[k];
if (isRequired(f) && !hasOwn(v,k)) throw new Error(`Missing required key "${k}" at "${pathStr}"`);
if (hasOwn(v,k)) validateValue(v[k], f, pathStr ? `${pathStr}.${k}` : k);
}
return;
}
case 'vec': {
if (!Array.isArray(v)) throw new TypeError(`Expected array at "${pathStr}"`);
for (let i=0;i<v.length;i++){
const el = v[i];
if (el === undefined) throw new TypeError(`Undefined not allowed at "${pathStr}[${i}]"`);
validateValue(el, s.item, `${pathStr}[${i}]`);
}
return;
}
default: throw new Error(`Unknown schema kind at "${pathStr}"`);
}
}
function schemaAt(schema, keys){
let cur = schema;
for (let i=0;i<keys.length;i++){
const seg = keys[i];
if (cur.kind==='dict'){
if (typeof seg!=='string' || !hasOwn(cur.fields, seg)) {
throw new Error(`Missing key at "${pstr(keys.slice(0,i+1))}" (schema)`);
}
cur = cur.fields[seg];
} else if (cur.kind==='vec'){
if (typeof seg!=='number') throw new TypeError(`Expected array index at "${pstr(keys.slice(0,i+1))}"`);
cur = cur.item;
} else {
throw new TypeError(`Cannot descend into non-structured node at "${pstr(keys.slice(0,i+1))}"`);
}
}
return cur;
}
// Build defaults from schema 'dft' properties (only necessary parts)
function defaultsFromSchema(s){
if (hasDefault(s)) return cloneDefaultValue(s.dft);
switch(s.kind){
case 'dict': {
let anyFilled = false;
const o = {};
for (const k in s.fields){
if (!hasOwn(s.fields,k)) continue;
const child = s.fields[k];
if (hasDefault(child)) {
o[k] = cloneDefaultValue(child.dft);
anyFilled = true;
} else {
const sub = defaultsFromSchema(child);
if (sub !== undefined) { o[k] = sub; anyFilled = true; }
}
}
return anyFilled ? o : undefined;
}
case 'vec': {
return undefined; // vec without dft returns undefined (no generation)
}
default:
return undefined; // num/str/bool/obj/any without dft returns undefined
}
}
function cloneDefaultValue(v){
if (Array.isArray(v)) return v.map(cloneDefaultValue);
if (isPlain(v)) { const o={}; for (const k in v){ if (hasOwn(v,k)) o[k]=cloneDefaultValue(v[k]); } return o; }
return v;
}
// dict-specific: Deep merge following schema (defaults ← initialPatch)
function deepMergeDictBySchema(schema, base, patch){
if (base !== undefined && !isPlain(base)) throw new TypeError('internal: base must be dict or undefined');
if (patch !== undefined && !isPlain(patch)) throw new TypeError('internal: patch must be dict or undefined');
if (base === undefined && patch === undefined) return undefined;
const out = {};
for (const k in schema.fields){
if (!hasOwn(schema.fields,k)) continue;
const childSch = schema.fields[k];
const baseV = (base && hasOwn(base,k)) ? base[k] : undefined;
const patchHas = patch && hasOwn(patch,k);
const patchV = patchHas ? patch[k] : undefined;
let merged;
if (childSch.kind === 'dict') {
merged = deepMergeDictBySchema(childSch, baseV, patchHas ? patchV : undefined);
} else if (childSch.kind === 'vec') {
merged = (patchHas ? patchV : baseV);
} else {
merged = (patchHas ? patchV : baseV);
}
if (merged !== undefined) out[k] = merged;
}
if (patch) {
for (const k in patch){
if (!hasOwn(patch,k)) continue;
if (!hasOwn(schema.fields,k)) throw new Error(`Unknown key "${k}" at "<initial>"`);
}
}
return out;
}
// ───────── Model Creation/Conversion ─────────
function attachSchema(root, schema){
Object.defineProperty(root, SYM_SCHEMA, { value:schema, enumerable:false, writable:false, configurable:false });
return root;
}
function getSchema(root){ return root[SYM_SCHEMA]; }
function isModel(x){ return Boolean(x && x[SYM_SCHEMA]); }
/**
* Creates a model instance from a schema with defaults and initial values
* @param {Object} schema - Schema definition to instantiate
* @param {Object} [initialPatch] - Initial values to merge with defaults
* @returns {Object} Model instance with embedded schema
* @throws {TypeError} If schema is invalid or values don't match schema
* @throws {Error} If required fields are missing
*
* Process:
* 1) Collects defaults from schema 'dft' properties
* 2) Merges defaults with initialPatch following schema rules
* 3) Validates final result strictly (throws on missing required fields)
*/
function instantiateSchema(schema, initialPatch){
assertSchemaNode(schema, '<root>');
const defaults = defaultsFromSchema(schema);
let merged;
if (schema.kind === 'dict') {
merged = deepMergeDictBySchema(schema, defaults, initialPatch);
} else if (schema.kind === 'vec') {
if (initialPatch !== undefined) {
if (!Array.isArray(initialPatch)) throw new TypeError('initialPatch for vec root must be array');
merged = initialPatch;
} else {
merged = (defaults !== undefined) ? defaults : undefined;
}
} else {
merged = (initialPatch !== undefined) ? initialPatch
: (defaults !== undefined ? defaults : undefined);
}
if (merged === undefined) merged = {}; // Validate as empty dict (adjust if needed)
validateValue(merged, schema, '<root>');
const root = cloneShallow(merged);
return attachSchema(root, schema);
}
/**
* Converts a model to plain object by removing hidden schema metadata
* @param {Object} model - Model with embedded schema
* @returns {Object} Plain object without schema symbols
*/
function toDict(model){
function strip(x){
if (Array.isArray(x)) return x.map(strip);
if (isPlain(x)){ const out={}; for (const k in x){ if (hasOwn(x,k)) out[k]=strip(x[k]); } return out; }
return x;
}
return strip(model);
}
// ───────── Path Utilities ─────────
/**
* Creates a cached path parser using template literals
* @returns {Function} Template tag function for path parsing
* @example
* const P = makePath();
* P`user.settings[0]` // Returns ['user', 'settings', 0]
*/
function makePath(){
const CACHE = new Map();
return (strings, ...vals) => {
const raw = strings.reduce((s, p, i) => s + p + (i<vals.length ? String(vals[i]) : ''), '');
let cached = CACHE.get(raw);
if (!cached){ cached = normPath(raw); CACHE.set(raw, cached); }
return cached;
};
}
// ───────── Retrieval ─────────
/**
* Retrieves a value from nested structure using path
* @param {Object} model - Model or plain object to query
* @param {Array|string} path - Path to value (array or dot notation)
* @param {*} [defaultValue] - Return value if path not found
* @returns {*} Value at path or defaultValue
* @throws {Error} If path not found and no defaultValue provided
*/
function getIn(model, path, defaultValue){
const keys = normPath(path); assertSafePath(keys);
let cur = model;
for (let i=0;i<keys.length;i++){
const k = keys[i];
if (!(cur && typeof cur==='object' && hasOwn(cur, k))) {
if (arguments.length < 3) throw new Error(`Missing key at "${pstr(keys.slice(0,i+1))}"`);
return defaultValue;
}
cur = cur[k];
}
return cur;
}
// ───────── Immutable Updates ─────────
/**
* Immutably sets a value at path in model
* @param {Object} model - Model with embedded schema
* @param {Array|string} path - Path to set value at
* @param {*} value - Value to set
* @returns {Object} New model with updated value
* @throws {Error} If path invalid, value violates schema, or setting undefined to required field
*/
function assocIn(model, path, value){
if (!isModel(model)) throw new Error('assocIn: model must be created by instantiateSchema');
const keys = normPath(path); assertSafePath(keys);
if (keys.length === 0) {
validateValue(value, getSchema(model), '<root>');
const nextRoot = isPlain(value) ? cloneShallow(value) : value;
return attachSchema(nextRoot, getSchema(model));
}
const sch = schemaAt(getSchema(model), keys);
// Check parent existence in actual data (no automatic initialization)
let cur = model;
for (let i=0;i<keys.length-1;i++){
const seg = keys[i];
if (!(cur && typeof cur==='object' && hasOwn(cur, seg))) {
throw new Error(`Missing key at "${pstr(keys.slice(0,i+1))}"`);
}
cur = cur[seg];
}
const parent = cur, last = keys[keys.length-1];
if (!(parent && typeof parent==='object')) throw new TypeError(`Parent is not object/array at "${pstr(keys.slice(0,-1))}"`);
if (isRequired(sch) && value === undefined) throw new Error(`Required value cannot be undefined at "${pstr(keys)}"`);
validateValue(value, sch, pstr(keys));
const rootClone = cloneShallow(model);
let node = rootClone, src = model;
for (let i=0;i<keys.length-1;i++){
const s = keys[i];
const nextSrc = src[s];
const nextNode = Array.isArray(nextSrc) ? nextSrc.slice()
: isPlain(nextSrc) ? Object.assign({}, nextSrc)
: (()=>{ throw new TypeError(`non-object at "${pstr(keys.slice(0,i+1))}"`); })();
node[s] = nextNode; node = nextNode; src = nextSrc;
}
node[last] = value;
return attachSchema(rootClone, getSchema(model));
}
/**
* Immutably updates a value at path using updater function
* @param {Object} model - Model with embedded schema
* @param {Array|string} path - Path to value to update
* @param {Function} updater - Function that takes current value and returns new value
* @returns {Object} New model with updated value
* @throws {TypeError} If updater is not a function
* @throws {Error} If updated value violates schema
*/
function updateIn(model, path, updater){
if (typeof updater!=='function') throw new TypeError('updateIn: updater must be function');
const keys = normPath(path); assertSafePath(keys);
const current = getIn(model, keys); // Throws if missing
const next = updater(current);
const sch = schemaAt(getSchema(model), keys);
if (isRequired(sch) && next === undefined) throw new Error(`Required value cannot be undefined at "${pstr(keys)}"`);
validateValue(next, sch, pstr(keys));
if (current === next) return model;
return assocIn(model, keys, next);
}
/**
* Immutably removes a value at path from model
* @param {Object} model - Model with embedded schema
* @param {Array|string} path - Path to remove
* @returns {Object} New model with value removed
* @throws {Error} If trying to remove required field or root
*/
function dissocIn(model, path){
if (!isModel(model)) throw new Error('dissocIn: model must be created by instantiateSchema');
const keys = normPath(path); assertSafePath(keys);
if (keys.length === 0) throw new Error('dissocIn: cannot remove root');
const sch = schemaAt(getSchema(model), keys);
if (isRequired(sch)) throw new Error(`Cannot remove required key at "${pstr(keys)}"`);
// Check actual data existence
let cur = model;
for (let i=0;i<keys.length-1;i++){
const seg = keys[i];
if (!(cur && typeof cur==='object' && hasOwn(cur, seg))) {
throw new Error(`Missing key at "${pstr(keys.slice(0,i+1))}"`);
}
cur = cur[seg];
}
const last = keys[keys.length-1];
if (!(cur && typeof cur==='object' && hasOwn(cur,last))) throw new Error(`Missing key at "${pstr(keys)}"`);
const rootClone = cloneShallow(model);
let node = rootClone, src = model;
for (let i=0;i<keys.length-1;i++){
const s = keys[i];
const nextSrc = src[s];
const nextNode = Array.isArray(nextSrc) ? nextSrc.slice()
: isPlain(nextSrc) ? Object.assign({}, nextSrc)
: (()=>{ throw new TypeError(`non-object at "${pstr(keys.slice(0,i+1))}"`); })();
node[s] = nextNode; node = nextNode; src = nextSrc;
}
if (Array.isArray(node)) {
if (typeof last !== 'number') throw new TypeError(`array index required at "${pstr(keys)}"`);
assertArrIndex(last, node.length, pstr(keys));
node.splice(last,1);
} else {
delete node[last];
}
return attachSchema(rootClone, getSchema(model));
}
/**
* Immutably merges an object patch at path (shallow merge)
* @param {Object} model - Model with embedded schema
* @param {Array|string} path - Path to object to merge into
* @param {Object} patch - Object to merge
* @returns {Object} New model with merged values
* @throws {TypeError} If target or patch is not a plain object
* @throws {Error} If patch contains unknown keys for dict schema
*/
function mergeIn(model, path, patch){
const keys = normPath(path); assertSafePath(keys);
const target = getIn(model, keys); // Throws if missing
if (!isPlain(target)) throw new TypeError(`Expected plain object at "${pstr(keys)}"`);
if (!isPlain(patch)) throw new TypeError(`Expected plain object (patch) at "${pstr(keys)}"`);
const sch = schemaAt(getSchema(model), keys);
if (sch.kind === 'dict'){
for (const k in patch){
if (!hasOwn(patch,k)) continue;
if (!hasOwn(sch.fields,k)) throw new Error(`Unknown key "${k}" at "${pstr(keys)}"`);
}
}
const merged = Object.assign({}, target, patch);
validateValue(merged, sch, pstr(keys));
return assocIn(model, keys, merged);
}
/**
* Sets a value at path only if currently undefined
* @param {Object} model - Model with embedded schema
* @param {Array|string} path - Path to check and potentially set
* @param {*} initialValue - Value to set if undefined
* @returns {Object} Original model if value exists, new model with value otherwise
* @throws {Error} If value violates schema
*/
function ensureIn(model, path, initialValue){
const keys = normPath(path); assertSafePath(keys);
let cur = model;
for (let i=0;i<keys.length-1;i++){
const seg = keys[i];
if (!(cur && typeof cur==='object' && hasOwn(cur, seg))) {
throw new Error(`Missing key at "${pstr(keys.slice(0,i+1))}"`);
}
cur = cur[seg];
}
const last = keys[keys.length-1];
if (hasOwn(cur, last)) return model;
const sch = schemaAt(getSchema(model), keys);
if (isRequired(sch) && initialValue === undefined) throw new Error(`Required value cannot be undefined at "${pstr(keys)}"`);
validateValue(initialValue, sch, pstr(keys));
return assocIn(model, keys, initialValue);
}
// ───────── Array Helpers ─────────
function requireVecSchema(model, keys){
const sch = schemaAt(getSchema(model), keys);
if (sch.kind !== 'vec') throw new TypeError(`Expected vec at "${pstr(keys)}"`);
return sch.item;
}
function pushIn(model, path, item){
const keys = normPath(path); assertSafePath(keys);
const itemSch = requireVecSchema(model, keys);
const arr = getIn(model, keys);
if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`);
validateValue(item, itemSch, `${pstr(keys)}[]`);
const next = arr.slice(); next.push(item);
return assocIn(model, keys, next);
}
function unshiftIn(model, path, item){
const keys = normPath(path); assertSafePath(keys);
const itemSch = requireVecSchema(model, keys);
const arr = getIn(model, keys);
if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`);
validateValue(item, itemSch, `${pstr(keys)}[]`);
const next = arr.slice(); next.unshift(item);
return assocIn(model, keys, next);
}
function insertIn(model, path, index, item){
const keys = normPath(path); assertSafePath(keys);
const itemSch = requireVecSchema(model, keys);
const arr = getIn(model, keys);
if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`);
if (!Number.isInteger(index) || index<0 || index>arr.length) {
throw new RangeError(`Invalid insert index at "${pstr(keys)}": ${index}`);
}
validateValue(item, itemSch, `${pstr(keys)}[${index}]`);
const next = arr.slice(); next.splice(index, 0, item);
return assocIn(model, keys, next);
}
function removeAtIn(model, path, index){
const keys = normPath(path); assertSafePath(keys);
const arr = getIn(model, keys);
if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`);
assertArrIndex(index, arr.length, pstr(keys));
const next = arr.slice(); next.splice(index,1);
return assocIn(model, keys, next);
}
function removeWhereIn(model, path, predicate){
if (typeof predicate!=='function') throw new TypeError('predicate must be function');
const keys = normPath(path); assertSafePath(keys);
const itemSch = requireVecSchema(model, keys);
const arr = getIn(model, keys);
if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`);
const next = arr.filter(x => !predicate(x));
validateValue(next, { kind:'vec', item:itemSch }, pstr(keys));
return assocIn(model, keys, next);
}
function updateAtIn(model, path, index, updater){
if (typeof updater!=='function') throw new TypeError('updater must be function');
const keys = normPath(path); assertSafePath(keys);
const itemSch = requireVecSchema(model, keys);
const arr = getIn(model, keys);
if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`);
assertArrIndex(index, arr.length, pstr(keys));
const cur = arr[index];
const nxt = updater(cur);
validateValue(nxt, itemSch, `${pstr(keys)}[${index}]`);
if (cur === nxt) return model;
const out = arr.slice(); out[index] = nxt;
return assocIn(model, keys, out);
}
function moveIn(model, path, fromIndex, toIndex){
const keys = normPath(path); assertSafePath(keys);
const arr = getIn(model, keys);
if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`);
assertArrIndex(fromIndex, arr.length, pstr(keys));
assertArrIndex(toIndex, arr.length, pstr(keys));
if (fromIndex === toIndex) return model;
const out = arr.slice();
const item = out.splice(fromIndex,1)[0];
out.splice(toIndex,0,item);
return assocIn(model, keys, out);
}
function swapIn(model, path, i, j){
const keys = normPath(path); assertSafePath(keys);
const arr = getIn(model, keys);
if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`);
assertArrIndex(i, arr.length, pstr(keys));
assertArrIndex(j, arr.length, pstr(keys));
if (i===j) return model;
const out = arr.slice(); const t = out[i]; out[i] = out[j]; out[j] = t;
return assocIn(model, keys, out);
}
function mapIn(model, path, mapper){
if (typeof mapper!=='function') throw new TypeError('mapper must be function');
const keys = normPath(path); assertSafePath(keys);
const itemSch = requireVecSchema(model, keys);
const arr = getIn(model, keys);
if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`);
const out = arr.map(mapper);
validateValue(out, { kind:'vec', item:itemSch }, pstr(keys));
return assocIn(model, keys, out);
}
function filterIn(model, path, predicate){
if (typeof predicate!=='function') throw new TypeError('predicate must be function');
const keys = normPath(path); assertSafePath(keys);
const itemSch = requireVecSchema(model, keys);
const arr = getIn(model, keys);
if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`);
const out = arr.filter(predicate);
validateValue(out, { kind:'vec', item:itemSch }, pstr(keys));
return assocIn(model, keys, out);
}
function sortByIn(model, path, keyExtractor, compareFn){
const keys = normPath(path); assertSafePath(keys);
const itemSch = requireVecSchema(model, keys);
const arr = getIn(model, keys);
if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`);
const out = arr.slice();
if (typeof compareFn === 'function') out.sort(compareFn);
else if (typeof keyExtractor === 'function') {
out.sort((a,b)=> {
const ka = keyExtractor(a), kb = keyExtractor(b);
return ka<kb ? -1 : ka>kb ? 1 : 0;
});
} else {
out.sort();
}
validateValue(out, { kind:'vec', item:itemSch }, pstr(keys));
return assocIn(model, keys, out);
}
function uniqByIn(model, path, keyExtractor){
if (typeof keyExtractor!=='function') throw new TypeError('keyExtractor must be function');
const keys = normPath(path); assertSafePath(keys);
const itemSch = requireVecSchema(model, keys);
const arr = getIn(model, keys);
if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`);
const seen = new Set(); const out = [];
for (const el of arr){ const k = keyExtractor(el); if (!seen.has(k)){ seen.add(k); out.push(el); } }
validateValue(out, { kind:'vec', item:itemSch }, pstr(keys));
return assocIn(model, keys, out);
}
function toggleIn(model, path, value){
const keys = normPath(path); assertSafePath(keys);
const arr = getIn(model, keys);
if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`);
const idx = arr.indexOf(value);
const out = arr.slice();
if (idx === -1) out.push(value); else out.splice(idx,1);
return assocIn(model, keys, out);
}
function setAddIn(model, path, value){
const keys = normPath(path); assertSafePath(keys);
const arr = getIn(model, keys);
if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`);
if (arr.indexOf(value) !== -1) return model;
const out = arr.concat([value]);
return assocIn(model, keys, out);
}
function setRemoveIn(model, path, value){
const keys = normPath(path); assertSafePath(keys);
const arr = getIn(model, keys);
if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`);
const idx = arr.indexOf(value); if (idx<0) return model;
const out = arr.slice(); out.splice(idx,1);
return assocIn(model, keys, out);
}
function upsertByIn(model, path, keyFn, item){
if (typeof keyFn!=='function') throw new TypeError('keyFn must be function');
const keys = normPath(path); assertSafePath(keys);
const itemSch = requireVecSchema(model, keys);
const arr = getIn(model, keys);
if (!Array.isArray(arr)) throw new TypeError(`Expected array at "${pstr(keys)}"`);
validateValue(item, itemSch, `${pstr(keys)}[]`);
const k = keyFn(item);
let idx = -1;
for (let i=0;i<arr.length;i++){ if (keyFn(arr[i])===k){ idx=i; break; } }
if (idx === -1) return pushIn(model, keys, item);
return updateAtIn(model, keys, idx, () => item);
}
// ───────── Transaction ─────────
/**
* Performs multiple updates in a transaction-like manner
* @param {Object} model - Model to update
* @param {Function} fn - Function that receives transaction API
* @returns {Object} Final updated model
* @example
* transaction(model, tx => {
* tx.assocIn(['user', 'name'], 'Alice')
* .updateIn(['count'], n => n + 1)
* .pushIn(['items'], newItem);
* })
*/
function transaction(model, fn){
let draft = model;
const api = {
assocIn: (p,v) => (draft = assocIn(draft, p, v), api),
updateIn: (p,f) => (draft = updateIn(draft, p, f), api),
dissocIn: (p) => (draft = dissocIn(draft, p), api),
mergeIn: (p,pat) => (draft = mergeIn(draft, p, pat), api),
pushIn: (p,x) => (draft = pushIn(draft, p, x), api),
insertIn: (p,i,x) => (draft = insertIn(draft, p, i, x), api),
updateAtIn:(p,i,f) => (draft = updateAtIn(draft, p, i, f), api),
removeAtIn:(p,i) => (draft = removeAtIn(draft, p, i), api),
};
fn(api);
return draft;
}
// ───────── Exports ─────────
return Object.freeze({
// Type/Modifiers
num, str, bool, obj, dict, vec, any, req, opt,
// Model
instantiateSchema, isModel, toDict,
// Path
makePath,
// Retrieval
getIn,
// Updates
assocIn, updateIn, dissocIn, mergeIn, ensureIn,
// Arrays
pushIn, unshiftIn, insertIn, removeAtIn, removeWhereIn, updateAtIn,
moveIn, swapIn, mapIn, filterIn, sortByIn, uniqByIn, toggleIn, setAddIn, setRemoveIn, upsertByIn,
// Transaction
transaction,
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment