JSONType Fields: Live Typed Objects and Opt-In Validation
A JSONType field is a text column that stores JSON, declared on EntityField with
JSONType (the interface name), JSONTypeIsArray, and JSONTypeDefinition (the TypeScript source
of the interface). CodeGen turns it into a typed <Field>Object accessor on the generated entity
class. This guide covers what that accessor does, its limits, and the opt-in validation layer
(@mjValidate, JSON-Schema-style tags, @CHECK).
Setting a JSONType on a field is a metadata step — see Migration → CodeGen Workflow § The JSONType Pattern.
1. The accessor is a live view
Section titled “1. The accessor is a live view”const rec = await md.GetEntityObject<WidgetEntity>('Widgets', user);await rec.Load(id);
rec.SettingsObject.Features[0].Enabled = true; // in-place, any depthrec.SettingsObject.Features.push({ Name: 'x', Enabled: false });delete rec.SettingsObject.NotificationEmail;await rec.Save(); // all three edits are persistedBefore this behavior existed the accessor parsed the text once, cached the object, and only
re-serialized in its setter. An in-place edit changed a throwaway copy and Save() silently wrote
nothing. The accessor now returns a live view backed by JSONFieldBinding
(@memberjunction/core, packages/MJCore/src/generic/jsonFieldBinding.ts), reached through two
protected BaseEntity helpers, GetJSONFieldObject / SetJSONFieldObject. Generated accessors are
one-line delegations to them; the logic lives once, in core.
Semantics
Section titled “Semantics”| Situation | Behavior |
|---|---|
Mutate at any depth (o.a.b = 1, arr.push(x), splice, sort, delete o.k) | Raw field is updated through Set(), so it is dirty, change events fire, Save() persists it |
| Read the same member twice | Same reference (o.a === o.a) |
| Assign a plain object/array | Adopted; later edits through your own reference are still picked up (see safety net) |
Assign the live object back (rec.X = rec.X, or const c = rec.X; c.A = 1; rec.X = c; c.B = 2) | No new generation: nothing is detached, no-op when nothing changed, later edits through c still persist |
Assign a nested node as the new root (rec.X = rec.X.Child) | Stored as an independent copy; the old document’s references detach |
| Assign a live object from another record | Copied |
A write that changes nothing (o.x = o.x) | No Set(), record stays clean |
Raw text changes by another route (Load, LoadFromData, Set, Revert, NewRecord) | Next accessor read re-parses; references you obtained earlier are detached |
| Write through a detached reference | Throws — a silent no-op would lose the write |
| Text differs only in formatting (whitespace/key order) after a reload | Tree is kept; references stay live |
Reference held across Save() | Stays live |
null / empty raw value | Accessor returns null; assigning null clears the field |
| JSON array root | Returned as an array proxy |
Primitive JSON root ("abc", 5) | Returned unwrapped |
| Invalid JSON text | Reading the accessor throws |
Only plain objects and arrays are wrapped. Date, Map, class instances etc. are left alone
(wrapping breaks their internal slots), which cannot occur in parsed JSON anyway.
The safety net
Section titled “The safety net”An object you assign into the tree and then mutate through your original reference is invisible
to the proxy. BaseEntity therefore flushes every materialized JSON object (re-serialize, Set() on
difference) before Validate() and at the start of Save(). Only fields whose accessor was read are
visited, so entities without JSONType fields pay nothing.
Limits — read these
Section titled “Limits — read these”-
structuredClone,postMessage, IndexedDB, andObject.freezeon a live value.structuredClone(proxy)throwsDataCloneError. UseToPlainJSON(value)from@memberjunction/core— a typed deep plain copy — whenever a value from an accessor leaves the entity:import { ToPlainJSON } from '@memberjunction/core';const copy = structuredClone(ToPlainJSON(rec.SettingsObject));worker.postMessage(ToPlainJSON(rec.SettingsObject));Do not
Object.freezea live view (orObject.isFrozen-guard on one): freeze aToPlainJSONcopy instead so the entity’s own tree is never frozen. -
Identity. The view is not
===the raw parsed object. Nobody else holds that object. -
Detached references. After
Load/Revert/Setreplaced the data, re-read from the accessor. -
Angular.
[(ngModel)]="rec.SettingsObject.Name"writes go through the proxy’ssettrap, so they update the entity and dirty it. Do not copy the object into component state expecting edits to flow back; bind to the entity. -
Cost. Each mutation re-serializes the field (config blobs are small). A bulk edit loop of thousands of writes is fine but not free; batch into a plain copy and assign once if it matters.
2. Opt-in validation
Section titled “2. Opt-in validation”Nothing validates JSONType content unless you opt in. Untagged JSONTypes generate exactly the code
they always did (z.any(), no new Validate() members) apart from the accessor delegation above.
Opt in with a JSDoc tag on the root interface (the one named by EntityField.JSONType):
/** * @mjValidate */export interface IScheduleConfig { Version: number; Windows: IWindow[];}@mjValidate warn reports every problem as a Warning — surfaced to the user, but the save is not
blocked. Nested types are reached from the root; a shared interface is opted in everywhere it is
bound.
Tags are JSDoc, not decorators. TypeScript forbids decorators on interface members, and a parse error would silently demote the field to a plain string. Put the tag comment on its own lines immediately before the declaration or member. A comment on the same line as the opening
{is not attached to anything by the TypeScript parser; CodeGen warns about such orphaned tags.
What gets generated
Section titled “What gets generated”- Exported structural Zod schema consts (
<Entity>Entity_<Type>Schema, module scope), used by the generatedValidate()and available to consumers toz.infer/safeParsethe object shape. The field’s own column entry in<Entity>Schemastaysz.any()exactly as for an untagged field: the column’s value everywhere (Get,GetAll,LoadFromData, GraphQL, raw rows) is JSON text, so typing it as the interface would be a lie and would break anything that parses row data with the schema. The typed object view is the<Field>Objectaccessor. Opted-in declarations are always emitted withexport(the exported consts name them). - A
Validate()override (generalized: emitted when table CHECKs or opted-in JSON validators exist) that runssuper.Validate(), table-CHECK validators, then for each opted-in field: the structuralsafeParse, then — only if the structure is valid — the tag and@CHECKrules.
A field is validated only when it is dirty or the record is new, so opting a type in never blocks
unrelated edits of existing rows. Invalid JSON text produces one clear error and skips the rest.
Errors carry path sources: Configuration.Windows[2].EndHour.
Structural mapping
Section titled “Structural mapping”Supported: interfaces, type aliases, optional members, string | number | boolean | null, string /
number / boolean literal types and unions, arrays (T[], Array<T>), nested and referenced local
types (recursion is handled lazily), Record<string, T>, index signatures, unknown/any, and
interface extends. Anything unsupported becomes a permissive schema for that sub-tree only
plus a CodeGen warning — CodeGen never fails the run over it. Output is deterministic.
JSON-Schema-style tags
Section titled “JSON-Schema-style tags”| Tag | Applies to | Effect |
|---|---|---|
@minimum @maximum @exclusiveMinimum @exclusiveMaximum @multipleOf | number | numeric bound |
@minLength @maxLength @pattern | string | length / regex |
@format email|uri|uuid|date-time|date | string | format check |
@minItems @maxItems @uniqueItems | array | size / uniqueness |
@CHECK rules
Section titled “@CHECK rules”Repeatable. Two forms:
@CHECK ts:(<TypeScript boolean expression>)— compiled as written, no model involved.valueis the object the tag is scoped to (see Scope below: on a scalar property that is the enclosing object, so writevalue.EndHour >= 0, notvalue >= 0);rowis the owning entity (read only).ts:expressions are taken literally: null handling is yours. Each expression is type-checked against its value type at CodeGen time; one that does not compile is skipped with a CodeGen error.@CHECK (<SQL boolean expression>)— translated to TypeScript by theCodeGen: JSON Check Parserprompt exactly the way table CHECK constraints are, then cached (below). The model must also returnTestCases({ Value, Row?, Expected }); CodeGen executes them against the compiled body in a separatevmcontext with a time limit (microtasks included) and discards a translation that fails any, that returns none, or — when the value type has optional/nullable members — that has no case with such a member absent or null. Thevmcontext is a correctness guard, not a security boundary: the same body is emitted into the entity class and runs in-process there, so review it like any generated code.
Scope mirrors SQL: on a property, names resolve against the enclosing object (like a column
CHECK); on an interface, the rule is object-level (like a table CHECK); on an array-typed
property, it is applied to each element. row.<Column> refers to the owning entity’s columns.
NULL semantics (SQL form). A SQL CHECK passes when its predicate is UNKNOWN. The prompt tells the
model that generated code must pass when a referenced optional member is null/undefined.
/** * @mjValidate */export interface IWindow { StartHour: number; /** * @minimum 0 * @maximum 23 * @CHECK ts:(value.EndHour >= value.StartHour) */ EndHour: number; Days?: string[];}
/** * @mjValidate warn * @CHECK (StartHour < EndHour) */export interface ISlot { StartHour: number; EndHour: number;}Regenerating after a definition change
Section titled “Regenerating after a definition change”A JSONType definition lives in metadata, so editing it (members, @mjValidate, tags) changes no
database column. After mj sync push, run mj codegen --skipdb to re-emit the entity classes: a
full mj codegen only rebuilds schemas whose entities changed in its database phase (see
CodeGen large-schema guide § dirty-schema scoped regen). The one
exception it handles itself: when a full run newly translates a SQL @CHECK, the entities bound to that
type are rebuilt in the same run.
SQL @CHECK caching
Section titled “SQL @CHECK caching”Translations are stored in __mj.GeneratedCode under the category CodeGen: JSON Validators,
keyed by JSONType name | property path | normalized check text | hash of the value's shape (its
members’ names, optionality and types) — not by entity field. A shared interface generates once;
editing the tag text (not merely re-flowing whitespace) or the members the rule sees regenerates, and two
entities declaring a same-named type differently get separate translations. A rule that reads
row.<Column> is also keyed by the owning entity, since its columns differ per entity. Reads
are unconditional, so --no-ai runs preserve committed validators; only generation is gated by the
ParseCheckConstraints feature. LLM output is emitted only after it passes a compile check (parses,
returns a value, no ${ placeholders, type-checks against the interface); otherwise the rule is
skipped with a CodeGen error — CodeGen never writes code that breaks the package build. Old cache
rows are left in place when a rule’s text changes.
Things to know
Section titled “Things to know”- SQL→TS NULL semantics are NOT verified mechanically. They are enforced by the prompt, the compile
check, and the model’s own
TestCases(a self-consistency guard: the body must agree with the cases its author wrote, including an absent-optional case). That catches a body that does not do what its author says; it cannot prove the cases themselves match the SQL. Review every translated rule in the PR that introduces it —GeneratedCoderows in theCodeGen: JSON Validatorscategory and the emittedValidate()body are both reviewable. Use@CHECK ts:(…)when you need exact semantics. ValidateAsyncis out of scope: referential rules (“this ID exists”) are not expressible.- Inherited interface properties carrying rules re-key per derived declaration.
- Prefix rewriting of type names for opted-in types is AST-based, so tag bodies and member names that coincide with a type name are not corrupted.
3. Testing your JSONType
Section titled “3. Testing your JSONType”- Core behavior:
packages/MJCore/src/__tests__/baseEntity.jsonField*.test.ts. - Generated code:
packages/CodeGenLib/src/__tests__/entity-subclass-jsontype*.test.ts. - Live database: the
jsontype-live-syncintegration bundles (IT99 server, IT100 client wire), which prove edit →Save()→ fresh load through a real provider.