North star: JSON-Schema + predicates as the single source of truth for types

Status: strategic direction (2026-07-06, user-set). Possibly post-1.0. Use as a decision lens NOW. Related: $predicate keyword (src/lang/predicate-schema.ts), createPredicateEvaluator, the tjs-lang/css library, docs/ambient-contracts.md, PRINCIPLES.md.

The principle

A TJS type is, canonically, a JSON-Schema node — optionally carrying a $predicate (a verified-pure predicate cluster) for the computational part that plain JSON-Schema can't express. Everything else — examples-as-types (x: 0), TS-derived types, Type/Generic declarations, TypeDescriptor — is surface syntax or an internal projection of that one canonical form, not a competing source of truth.

Together they express the full range of what TJS types mean, in a standard, serializable, inspectable, cross-language form.

The decision lens

For any architecture or implementation choice, ask:

Does this move types toward — or away from — being fully expressible as JSON-Schema + $predicate?

Concretely, toward looks like:

Away looks like: a TJS-only internal type IR that accretes features JSON-Schema

Why this is the right endgame

The priority that unlocks it: a small, portable predicate VM

For "types across language boundaries" to be real, $predicate must run anywhere, not just in JS. The priority is a reference implementation of a tiny, portable VM that safely evaluates the predicate subset — ideally smaller than a JS runtime, implementable in a few hundred lines in any language.

It's tractable precisely because the predicate subset is deliberately minimal (the verifier enforces it): pure, synchronous, no loops (recursion + array methods only), fuel-bounded, no IO, a whitelist of pure operations (member access, comparisons, &&/||/ternary, typeof, a fixed set of string/array/Math/regex methods). That's a small tree-walking interpreter — no closures-over-mutable-state, no async, no allocation surprises, no host access.

Key architectural implication — carry the AST, not (only) the source. Today $predicate is predicate source (JS/AJS text). A portable VM in Rust/Go/Python shouldn't have to embed a JS parser. So the canonical portable form of a predicate should be its serialized AST (JSON) — which is the original AJS thesis ("code travels as data") applied here. Source stays the authoring form; the serialized AST is the wire/exec form the small VM walks. A $predicate could carry either, with the AST as the portable default.

asCompared — the missing half of a type

Status: designed, not built. Targeted as a patch after 0.13.3 — it is additive and non-breaking, so it need not wait for 0.14.

The gap

A type today answers one question: membership — does v satisfy T? That is what predicate is. It cannot answer the other one: equivalence — are a and b the same, as T?

Tonio's framing, which states it better than anything else here:

a number is a number for comparison, but -1 is not a positive integer

Those are independent. -1 failing Positive is membership. 1 and 1.0 being the same number is equivalence. A type that can only answer the first is half a type.

The hook that exists, and why it cannot close the gap

goIs dispatches to [Symbol.for('tjs.equals')] or .Equals — a method bound to the value, Java-style. Three things follow, and each is fatal on its own:

  1. Primitives cannot participate. customEquals returns null for anything where typeof x !== 'object'. There is no way to say "compare these floats with tolerance".
  2. You must own the class. Making a Firestore Timestamp compare as a timestamp would mean monkey-patching a prototype you do not own — prototype pollution, which this codebase treats as a security boundary (FORBIDDEN_KEYS).
  3. Equivalence is type-relative, so it cannot live on the value. 1.0000001 and 1.0 are equal as Approximate, distinct as Float. Only the comparator knows which question is being asked. The current design put the answer in the wrong place.

The language already does this — for three types, hardcoded

if (v instanceof String)  return String.prototype.valueOf.call(v)
if (v instanceof Number)  return Number.prototype.valueOf.call(v)
if (v instanceof Boolean) return Boolean.prototype.valueOf.call(v)

unwrapBoxed is a comparator registry with exactly three entries. It is precisely "compare this object as something else" — a String instance normalised to a string before comparison. The semantics were decided and shipped; what was never shipped is a way to add a fourth entry.

asCompared generalises those three hardcoded cases into an open registry. They become its base cases rather than a special rule.

A projection, not a comparison

extend Timestamp {
  asCompared() { return this.seconds * 1000 + this.nanoseconds / 1e6 }
}

The name is deliberate: it says what the value is, for comparison — not how to compare it. That distinction carries real weight:

It is consumed by Eq, Is AND toBool

This is the part that makes it more than an equality feature, and it answers the question that prompted the design — why don't Eq and toBool use the computed comparator? Because there isn't one, and both need it.

toBool is Boolean(unwrapBoxed(v)). An errored service result is an object, objects are truthy, so if (result) takes the success branch and the type cannot say otherwise:

toBool({ ok: false, error: 'timeout' })   ->  true

With asCompared → false, if (result) is correct. The same applies to any library's own boolean wrapper, which unwrapBoxed cannot see because it only knows Boolean.

Return type: a primitive, or nothing

Allowed: number, string, boolean, null, undefined.

why
number ordering falls out free (<, >, sorting)
string identity and normalisation — URL → href, case-insensitive keys; and it is what the existing String entry already returns
boolean feeds toBool; errored results and foreign boolean wrappers
null / undefined Option/Maybe — None projects to null, and Eq already treats the two as equal

Rejected:

The rule is simpler than the list: project to a primitive, or to nothing. Which is exactly what unwrapBoxed already does.

Participation is signalled by whether the type declares asCompared, not by what it returns — otherwise null/undefined would be ambiguous with declining.

Resolved: a registry CHAIN, rooted at the host

The scoping question is settled, and the answer reframes what asCompared is.

The registry chain is TJS's view of the type environment.

module registry      ← `extend` lands here; local, does not leak (SHIPPED GLOBAL in
                       0.13.4 by mistake — fixed in 0.13.5; see the CHANGELOG)
       ↑ inherits
globalThis.__tjs     ← the shared view, one of them, installed before anything runs
       ↑ inherits
the type ITSELF      ← `class Foo { asCompared() {…} }` (0.13.6)
       ↑ inherits
host built-ins       ← String / Number / Boolean base entries

Lookup walks up; writes land locally. A module can say "a Firestore Timestamp compares as millis" without any other module seeing it, while everyone inherits the shared view of String.

The type's own method is the layer beneath the registries, and it is the one that makes the chain complete rather than merely long. The registries are keyed by constructor.name — that is a third party describing a type it does not own, which is why a registration is an override and is consulted first. A method is the type answering for itself, so it is the default everyone inherits, exactly like the host entries below it.

It is also the only layer a Proxy can reach (#33), and that turned out to be load-bearing for a real consumer. tosijs 2.0's boxed scalars are proxies over new Number(0) serving a live value from the get trap. Neither layer above works for them:

A get trap can serve a method. That is the whole reason this layer is a method and not another table.

Duck-typed on the name, deliberately. [tjsEquals] and .Equals are dispatched the same way, and the line this codebase draws is the right one: a hook a type declares about itself is categorically different from a boxed primitive intercepting a comparison it never agreed to join. Nobody writes asCompared by accident — unlike valueOf, which every Date and Number already has, and which is precisely why unwrapBoxed reads the internal slot instead of calling it. Containment is unchanged either way: the probe and the call are both fail-soft, and a projection that is not a primitive is ignored rather than honoured.

The emitted preamble already encodes exactly this link:

const __tjs = globalThis.__tjs?.createRuntime?.() ?? { …inline stubs }

Why the chain is only two levels deep — and must be. A module's parent is globalThis, not its importer. Modules form a flat GRAPH, not a tree: at runtime nothing knows who imported it, and a module imported by two others would have two candidate parents. Which one it got would then depend on evaluation order — the same module producing different comparison semantics depending on who won the import race. Is(a, b) answering differently by load order is about the worst property a comparator can have: nondeterministic, invisible and unreproducible.

So lexical inheritance is not merely hard here, it is incoherent. The flat module graph forces the flat chain, and each layer's determinism is then trivially statable: a module's own extends are its own, the global layer is whatever was installed before anything ran, and the base is fixed.

What this makes true, that the earlier framing missed:

Prerequisite: extend's runtime half is dead code

This must be fixed first, and it is not a parallel task. Measured at 0.13.3:

receiver emitted works?
literal — 'hello'.cap() __ext_String.cap.call('hello') yes
annotated param — s: '' s.cap() no — s.cap is not a function
untyped param s.cap() no

Only a LITERAL receiver is rewritten. Even a parameter the transpiler knows is a string emits an unrewritten call, which needs String.prototype.cap — deliberately absent.

The registry is write-only: emitted code calls registerExtension, and the emitter never emits a resolveExtension call anywhere. Entries go in; nothing looks them up. CLAUDE-TJS-SYNTAX.md:656 claims "Runtime fallback via registerExtension()/resolveExtension() for unknown types" — the resolver exists, is exported, walks prototype chains correctly, and is never called.

So extend is currently local by ACCIDENT rather than design: local because the only working mechanism is a lexical rewrite, and the mechanism that would make it non-local is unreachable. Wiring the resolver without deciding the chain first would turn it into the flat global table described above — the prototype pollution the feature exists to avoid.

asCompared cannot ride on extend until that half works, and fixing it is where the chain model actually gets built.

Still open

Implementation notes

Predicate is the umbrella — and it already exists, unnamed

Design position (Tonio, 2026-09-19): predicates stay plain functions under the hood, but within TJS they are instances of Predicate. Everything that answers "does v satisfy X" is a predicate; the declaration forms differ only in how they are constructed and how much they can tell you about themselves.

This started as an argument for renaming FunctionPredicate to FunctionType, on the grounds that it declares a function type. That argument proves too much: Type Age 0 also produces a runtime checker, so if producing a check disqualifies something from being a predicate, nothing in the language is one. The taxonomy was wrong at the root, and the rename was abandoned mid-flight.

The finding: the interface is already implemented

Every declaration form returns an object sharing exactly five members:

__runtimeType   check   description   strip   toJSONSchema

and each adds its own introspection on top:

form adds
Type Age 0 example, examples, default, schema, predicate
Enum Colour ['red','green'] values, keys, names, members
Union U [0, ''] values
FunctionPredicate Cb {…} params, returns, returnContract

So the common concept is not hypothetical — it is built, consistent across four constructs, and branded (__runtimeType: true). It has no name. That is the gap.

Why naming it is not cosmetic

A named umbrella gives one answer where there are currently four parallel ones:

What "plain functions under the hood" buys, and must not cost

The constraint that keeps this honest: a Predicate must remain an ordinary JavaScript function underneath. Not a class instance requiring a runtime, not a wrapper that has to be unwrapped at a boundary. That is what lets a verified predicate compile to native JS, travel as $predicate, and be called by code that has never heard of TJS — and it is the same "inside JavaScript" commitment the rest of the language rests on (guides/why-tjs.md).

FunctionPredicate then stops looking like an outlier and reads correctly: a Predicate whose subject happens to be a function, distinguished by richer introspection — it can report params and returns, not merely "yes, that is a function". The specialisation is in what it knows, not in what kind of thing it is.

The concrete difference: a Type is a Predicate that carries a WITNESS

Decided 2026-09-20. The relationship between Type and Predicate is not a hierarchy. It is what the predicate additionally carries, which makes it an instances story rather than a subclass one.

A Predicate can decide — check(v) -> boolean, and that is the entire contract. n % 2 === 0 has nothing else to offer; you cannot ask it for an even number.

A Type is built from an example, so it carries a witness value — and every other capability it has follows from that one fact:

Type('age', 0).check(5)   -> true      it decides, like any predicate
Type('age', 0).example    -> 0         it carries a witness
Type('age', 0).default    -> 0         ...so it can GENERATE
                                       ...so it can STRIP (it knows the shape)
                                       ...so it can describe STRUCTURE in JSON Schema

Generalised, the four existing forms differ only in what they carry:

carries can additionally
Predicate — nothing —
Type — one example generate, strip, describe structure
Enum / Union — the whole finite domain generate, enumerate → autocomplete for free
FunctionPredicate — a signature describe params/returns, check arity

Capabilities are properties present or absent, not subclasses. instanceof Predicate comes from a real prototype chain: brandPredicate (src/types/predicate-brand.ts) sets a predicate's prototype to Predicate.prototype, which itself links to Function.prototype, so a predicate keeps call/apply/bind and stays a function to everything that does not know better.

Superseded design, kept because the reasoning shows up elsewhere in this note: the first draft used Symbol.hasInstance on the brand, which spanned a plain object like the old Type(…) and an ordinary callable like isColor without touching prototypes. It stopped being necessary once runtime types became functions — there was no longer a plain-object population to span — and a real chain beats a stand-in for one. The class is claimed through a shape-versioned global slot so it is the SAME class across bundles (docs/runtime-fusion.md).

Autocomplete is NOT in the minimum, and that is load-bearing

A predicate drives autocomplete iff it carries values. Enum does, for free. A bare predicate does not — and suggest() already covers that case by mining the source (keyword sets, startsWith guards) rather than asking the predicate for something it cannot know.

Suggestion is therefore a capability some predicates have and others have recovered by analysis. Requiring it of the minimum would force every predicate to answer a question most cannot.

Sensible defaults: the minimum is ONE member

member default
check required — it is the function
description fn.name
toJSONSchema { $predicate: … } — claims no structure, which is exactly the progressive-enhancement story
strip identity — you cannot strip what you cannot describe
example / values absent; presence is the capability

This also resolves the strip-on-a-FunctionPredicate smell noted above: it is identity there, and should be inherited by default rather than separately implemented on each form. The five observed members stop being an accidental intersection and become one required member plus four defaults.

Type is already a Predicate with more attached — so nothing needs restructuring

Settled 2026-09-20 (Tonio). "Can't Type just be a predicate with more information attached?" — it can, and it already is. The extra information is properties on the object, so there is nothing to construct through. The "is Type built on Predicate or does it merely satisfy it" question does not need an answer; it dissolves.

Predicate an object with check — the function does the work under the hood
Type a Predicate with an example attached
Enum / Union a Predicate with a finite domain attached
FunctionPredicate a Predicate with a signature attached

Predicates are FUNCTIONS WITH PROPERTIES, not objects with a callable test. That was the original conception and it is the right one; an earlier draft of this section argued against it and was wrong on every count. The objections, and what measuring them showed:

objection raised measured reality
Two guards branch on typeof === 'object' (runtime.ts, and the inline __match stub) a two-line widening (|| typeof ex === 'function'), backward compatible — object-shaped types still match
Object.setPrototypeOf is expensive 2.7ms vs 0.7ms per 20,000 declarations (~0.1µs each), at module-init only. Non-issue
Old emitted files would not recognise a callable type no exposure. Emitted types are file-local consts, never exported, and the param check calls .check() directly rather than going through __match. A file only ever checks against types its own stub built

The shape, which needs no eval and no new Function:

const fn = (v) => /* the test */
Object.setPrototypeOf(fn, Type.prototype)      // real prototype chain
Object.assign(fn, { description, example, check: fn, __runtimeType: true })

Age(5)                  // true        — callable, the natural JS idiom
Age.check(5)            // true        — same function, cannot drift
Age instanceof Type     // true        — real instanceof, no Symbol.hasInstance
Age instanceof Predicate // true

Why this is better than the object-shaped alternative, beyond taste:

The one genuine finding, which is worth fixing on its own merits: a callable Type satisfies FunctionPredicate.check, because that check is literally v => typeof v === 'function' and so already accepts any function regardless of signature. Callable predicates add one more category to a check that is already too permissive. The defect is the weak check, and it exists today.

Minor and known: functions carry name and length as own properties. Both are configurable and no declaration form uses .name today, but a property named name would need care.

Open, and genuinely undecided

Open questions