Dictionary Defaults (Merge-on-Partial Object Arguments)

Status: Shipped (0.12.0). Spikes A+B done; Stage 0 (colon-form member validation), Stage 1 (the = merge behaviour; its mode directive was abolished 2026-08-02 and it is now unconditional in .tjs), and Stage 3 (deep-partial .d.ts) all shipped, plus the dict-default-excess-key lint. Stage 4 (dogfood in tosijs-3d) is the remaining follow-up. Feature class: Language semantics + runtime — a gated native-TJS mode Characterization: JS footgun pave

Provenance: drafted 2026-07-17 (without the syntax docs at hand), revised 2026-07-18 against empirical findings from the codebase. Claims in the original draft that contradicted existing grammar or measured behavior have been corrected rather than argued with; the corrections are marked.


⚠️ Where we diverge from WebIDL — read this before using the spec as a reference

This design borrows WebIDL's dictionary semantics, and the section numbers below (§5.x) refer to that spec. One rule is deliberately different, and it is the one most likely to surprise you if you know WebIDL.

WebIDL §5.4 TJS
Excess keys in the payload stripped passed through
Missing members filled from the default filled from the default (same)
Wrong member type rejected rejected (same)
__proto__ / constructor / prototype n/a rejected outright
function place(args = { x: 0, y: 0 }) { return args }

place({ x: 5, z: 9 })
//  WebIDL:  { x: 5, y: 0 }          ← z is gone
//  TJS:     { x: 5, y: 0, z: 9 }    ← z survives

Why WebIDL strips, and why we should not. A WebIDL dictionary is a wire format. Its job is to normalise an untrusted bag into exactly the declared shape before it crosses a boundary into browser internals, so anything undeclared is noise by definition. A TJS = {…} parameter is not a boundary — it is an options bag inside one program, and options bags in JavaScript routinely carry more than the callee declares. A caller forwards its own options; one object is passed to two functions; a component spreads props. Deleting the caller's data in that situation is not normalisation, it is loss.

TJS shipped the WebIDL behaviour in 0.12.0, with a once-per-site flight-recorder notice so the loss was at least visible in a log. Changed 2026-08-14 (0.13.0) for three reasons:

  1. It is silent where it matters. The notice appears in a recorder ring, not at the call site, and once per site — so the second call that loses data says nothing.
  2. It contradicted the rest of the language. Nothing else in TJS removes or rejects an excess key. Both structural checkers were opened in the same release, after they were found to disagree with each other (docs/type-identity.md).
  3. TypeScript cannot express the closed type this was enforcing. TS's excess-property check is a freshness lint on object literals, not a property of the type — assign through a variable and the same object is accepted — and there is no Exact<T> to opt into. A runtime rule stricter than anything the type system it mirrors can state is a rule users cannot reason about.

What you lose: if you were relying on a dictionary default to sanitise an untrusted payload, it no longer does. That was never a security boundary — the VM's capability membrane is — but if you want the WebIDL behaviour, strip explicitly:

const clean = ({ x, y }) => ({ x, y })

What did NOT change: members are still validated, missing members still fill from the default (recursively), and the prototype-pollution keys are still rejected outright — that one is a security guard, not a normalisation policy.

A performance note, since it cuts the friendly way: passing keys through means a complete payload carrying extra keys no longer needs a rebuild at all. It falls through on the untouched-identity path (invariant I3), where it used to be copied in order to be stripped.


1. Problem statement

JavaScript default parameters are atomic: (args = {x: 0, y: 0}) means default or payload, wholesale. A partial payload {x: 5} silently discards the default for y, yielding y === undefined. Nobody passing a partial options object intends this.

The consequences in the wild:

This is a pattern that is universally wanted, universally reimplemented, and reliably botched. That is the definition of a pave target.

The platform precedent

The DOM already has the correct semantics. Every options-bag Web API is a WebIDL dictionary, and WebIDL dictionaries default per member:

JS developers already have merge-semantics intuition drilled in by the entire platform API surface. Only userland functions exhibit atomic default-or-nothing, because JS function defaults cannot express what WebIDL dictionaries do. The JS behavior is the anomaly. tjs adopts the platform's model.


2. What current tjs actually does (measured 2026-07-18)

The original draft assumed partial payloads "in current tjs produce type errors." Measured, they do not — and the finding reshapes both the framing and the implementation plan:

// = form
function place(args = { x: 0, y: 0 }) {
  return args
}
place({ x: 5 }) // → {x: 5}         (y === undefined — plain JS semantics)
place() // → {x: 0, y: 0}   (JS evaluates the literal per call)

// colon form (required param, example)
function placeB(args: { x: 0, y: 0 }) {
  return args
}
placeB({ x: 5 }) // → {x: 5}         (no missing-member error)
placeB({ x: 's', y: 1 }) // → passes through (no member type check!)
placeB({ x: 5, y: 1, z: 9 }) // → passes through (no excess-key handling)
placeB(5) // → MonadicError    (the ONLY check that fires)

The emitted validation for an object param is, in its entirety, typeof args !== 'object' || args === null || Array.isArray(args). The full member shape is emitted into fn.__tjs.params metadata (kind: 'object', shape: {…}) — the check just never consumes it.

Two consequences:

  1. This feature is a semantics change to valid native-TJS programs, not an occupation of free space. Partial payloads work today, with JS semantics. That is fine — it is exactly what native-TJS rules are for (honest equality changed == itself; raw new Date() is not allowed) — but it must be framed and gated as a mode (§3).
  2. Member-level object validation must be built regardless (Stage 0). The merge is a phase of a validator that does not yet validate members. Bonus: as of 0.10.1, the inline Type(...).check() stub IS strict-structural (fixed under #21-adjacent work), so Type checks and param checks of the same shape currently disagree — Stage 0 resolves that inconsistency.

3. Mode gating (the missing section, now load-bearing)

Dictionary defaults are native-TJS behaviour, unconditional in .tjs (the mode directive was abolished 2026-08-02), and OFF under dialect: 'js' and for fromTS-originated code, and disabled by TjsCompat.

This is required by PRINCIPLES.md: dialect: 'js' must preserve plain-JS semantics, and merge-on-partial observably changes them (y === undefined → y === 0). The subset invariant is satisfied the same way == satisfies it — choosing .tjs is the opt-in.

The §6.1 purity restriction (compile error on impure default literals) is likewise native-only; raw new Date() not being allowed is the precedent for a mode making a JS-legal construct a compile error.


4. Design principles

  1. No new syntax. The existing declaration (args = {x: 0, y: 0}) already contains the shape, the types, and the defaults. The feature changes what the runtime does with it, not how it is written.
  2. A mode, honestly labeled. Changes the meaning of partial-payload calls in native tjs only (§3). Ship with a CHANGELOG "Changed" entry, not buried in "Added".
  3. Defaults are data, not effects. Default objects are restricted to structurally clonable literals (§6.1). This is what makes hoisting sound.
  4. Zero cost on the happy path. A complete payload passes through untouched: validation scan only, no allocation, no clone.
  5. The default object is inviolate. Neither the runtime nor user code can corrupt it. Dev builds enforce this mechanically (§7.2).

5. Semantics

5.1 Member states — resolved: required-ness lives at the PARAM level

: is the required marker; = is the defaulted marker. tjs already has both, at the level where required-ness actually belongs (Tonio, 2026-07-18 — resolving OQ1). No member-level marker exists or is needed:

Declaration The param Its members
(args: {x: 0}) required ALL required + type-checked (Stage 0, shipped)
(args = {x: 0}) defaulted ALL defaulted — merge-on-partial (the mode, this spec)

A "required member inside a defaults object" is a contradiction in terms — if the caller must supply it, it is not a default. The mixed case (a required id plus defaulted options) uses separate parameters — (id: '', opts = {...}) — which is the platform convention this spec is built on (addEventListener(type, listener, options): required positionals, then a dictionary of defaults). The draft's WebIDL-keeps-required argument is answered, not rejected: required things live in the colon form; the error still fires at the call site, just from the declaration level that already expresses it.

The draft also had an "Unchecked" member state marked x!. Dropped: ! does not parse inside object literals (verified), and it is the param/access safety marker. Spike A's required(example) wrapper is likewise cut — retained in the spike only as evidence the mechanism works if ever needed.

5.2 Trigger condition

A member's default fires only when the key is absent from the payload.

5.3 Recursion

Nested object literals in the default merge recursively, per-key, per-level:

const place = (args = { pos: { x: 0, y: 0 }, label: '' }) => {}
place({ pos: { x: 5 } })
// → {pos: {x: 5, y: 0}, label: ''}

Arrays are values, not merge targets. A payload array replaces the default array wholesale (element types still checked against the example element, per existing inference convention). Index-wise merging is explicitly rejected.

5.4 Excess keys

Keys present in the payload but absent from the default literal. The draft proposed "error in dev, strip in prod"; revised recommendation (pending Spike A evidence, OQ2): no dev/prod behavior split — this repo deliberately avoids behavior matrices. Instead:

5.5 Top-level absence

Calling with no argument: the function receives a fresh clone of the full default. (JS already produces a fresh object per no-arg call — the literal is evaluated per call — so hoist-plus-clone is observably identical. Verified.)

5.6 Prototype safety

Only own enumerable string keys participate. __proto__, constructor, and prototype in a payload are rejected outright — deriving the check from the canonical FORBIDDEN_KEYS list (src/forbidden-keys.ts), the single source shared by the VM guards, the linter, and this emitter, not a new list. Merge is exactly where prototype pollution lives; this closes the class cheaply.


6. Restrictions

6.1 Default expressions must be pure literals

The default must be an object literal composed of structurally clonable literals: primitives, object literals, array literals. Compile-time error otherwise (function calls, identifiers referencing live objects, new, Date.now(), getters/setters, computed keys). Native-mode-only (§3).

Rationale: JS evaluates default expressions per call; tjs hoists them (§7.1). For pure literals the two are observationally identical; for effectful expressions they diverge silently. The restriction closes the divergence at compile time.

(Escape valve if per-call computed defaults prove necessary: an explicit thunk form. Out of scope for v1.)


7. Runtime design

7.1 Hoisted template + shape descriptor

At transpile time, each qualifying default literal is hoisted to a module-level template (evaluated once — sound because §6.1 guarantees purity) plus a compact shape descriptor (member names, states, types, nested descriptors). The descriptor largely exists already: it is the fn.__tjs.params[…].type metadata the emitter produces today — Stage 0's job is making the emitted check consume it.

7.2 Template integrity

Dev builds: template deep-frozen at creation — any attempted write throws at the write site instead of corrupting call N+1. Production: freeze elided; §7.4's invariants and §7.3 guarantee the runtime never writes it and user code never receives it.

7.3 No-arg calls clone

A no-payload call receives a structural clone of the template, never the template itself — otherwise a function mutating its own args poisons every future call (the exact bug this paves, reintroduced through the back door).

7.4 Check-then-fill

Per call with a payload:

  1. Scan payload against the descriptor: validate present members' types, note absent defaulted members, error on absent required members, apply the excess-key policy (see the divergence note at the top — TJS passes them through). Recurse into nested dictionaries.
  2. Complete payload → return the payload as-is. Zero allocation. This is the hot path and must stay a pure read-only scan.
  3. Members absent → build ONE fresh output object: present members from the payload, absent members cloned from the template (never aliasing mutable template substructure into the result).

Invariants (encoded as tests in Spike A):

7.5 Failure mode

All violations (missing required, null-where-not-nullable, member type mismatch, excess key under a strict policy) surface through the existing runtime type-error channel (__tjs.typeError → MonadicError) with a path (args.pos.y). No new error machinery.


8. Interactions


9. Open questions

  1. Required marker (OQ1). RESOLVED 2026-07-18 — no marker. : vs = at the param level IS the required/defaulted distinction (§5.1); members of a default object are defaults by construction. Mixed shapes use separate params (platform convention). The spike's required(example) wrapper is cut from v1 (kept in the spike as evidence the mechanism works).
  2. Excess-key policy (OQ2). §5.4 carries the recommendation (strip + record + literal-call-site lint); Spike A implements all three candidate policies behind a switch to generate comparative evidence.
  3. Null admission (OQ3). §5.2 adopts "example is null ⇒ admits null" for the spike; confirm against real tosijs option shapes at Stage 4.
  4. Non-dictionary defaults (OQ4). (x = 0), (list = []) keep unchanged JS semantics — the feature triggers only on object-literal defaults. Encoded in Spike A.

10. Implementation plan

Spike-first; each stage lands independently.

Out of scope for v1: computed/thunked defaults, call-site default overrides, array merge strategies (rejected, not deferred).