Dictionary Defaults

An options object where each member has its own default, and passing a partial one fills in the rest. JavaScript makes you write that merge by hand, every time.

/#
## The JavaScript problem

A default parameter is ATOMIC — all or nothing:

    function drawBox(opts = { x: 0, y: 0, w: 100 }) { … }

    drawBox()            // { x: 0, y: 0, w: 100 }   the default
    drawBox({ x: 5 })    // { x: 5 }                 y and w are GONE

Pass any object and the whole default is replaced. So real code writes the
merge by hand:

    function drawBox(opts) {
      const { x = 0, y = 0, w = 100 } = opts ?? {}
      …
    }

— which works, but says nothing about `opts` in the signature, so the
documentation and the validation both live in the body.

## What TJS does

`=` on an object parameter means **per-member defaults with merge-on-partial**
(WebIDL dictionary semantics):

    function drawBox(opts = { x: 0, y: 0, w: 100 }) { … }

    drawBox({ x: 5 })    // { x: 5, y: 0, w: 100 }

Four properties worth knowing, each demonstrated below:

1. **Merge, not replace** — absent members take their default.
2. **Recursive** — a nested object merges member-by-member too.
3. **Members are validated** — a wrong type is a `MonadicError` naming the
   exact path, e.g. `f.opts.x`.
4. **Excess keys pass through** — TJS is not a bouncer. (0.12 stripped them;
   0.13.0 stopped, deliberately. See `docs/dictionary-defaults.md` for the
   WebIDL divergence.) A complete payload is returned **by identity** — no
   copy, so no allocation on the hot path.

`:` means required, `=` means defaulted. Mixed shapes use separate
parameters. Native `.tjs` only — `dialect: 'js'` and `fromTS` keep atomic
JavaScript defaults, because TJS must stay a superset.
#/

// --- 1. Merge, not replace ---
function drawBox(opts = { x: 0, y: 0, w: 100, label: '' }) {
  return `${opts.label || '(box)'} at ${opts.x},${opts.y} width ${opts.w}`
}

console.log('merge-on-partial:')
console.log('  {x:5}          ->', drawBox({ x: 5 }))
console.log('  {label:"hi"}   ->', drawBox({ label: 'hi' }))

// --- 2. Recursive: nested objects merge member-by-member ---
function layout(cfg = { margin: { top: 0, left: 0 }, name: '' }) {
  return `${cfg.name}: top=${cfg.margin.top} left=${cfg.margin.left}`
}

console.log('\nrecursive merge:')
// Only `top` is given — `left` still gets its default rather than vanishing.
console.log('  {margin:{top:3}} ->', layout({ margin: { top: 3 }, name: 'panel' }))

// --- 3. Members are validated, with the path in the error ---
function sized(opts = { x: 0, label: '' }) {
  return opts
}

console.log('\nvalidation:')
console.log('  x:"oops" ->', JSON.stringify(sized({ x: 'oops' })))

// --- 4. Excess keys pass through; complete payloads keep identity ---
console.log('\npass-through and identity:')
console.log('  extra key kept ->', JSON.stringify(sized({ x: 1, extra: 'yes' })))

const complete = { x: 9, label: 'all present' }
console.log('  same object?   ->', sized(complete) === complete)

test 'absent members take their default' {
  expect(drawBox({ x: 5 })).toBe('(box) at 5,0 width 100')
}

test 'a nested object merges member-by-member' {
  expect(layout({ margin: { top: 3 }, name: 'panel' })).toBe(
    'panel: top=3 left=0'
  )
}

test 'a wrong member type is a MonadicError naming the path' {
  const result = sized({ x: 'oops' })
  expect(isMonadicError(result)).toBe(true)
  expect(result.path).toContain('opts.x')
}

test 'excess keys are passed through, not stripped' {
  expect(sized({ x: 1, extra: 'yes' }).extra).toBe('yes')
}

test 'a complete payload is returned by identity — no copy' {
  const payload = { x: 9, label: 'all present' }
  expect(sized(payload) === payload).toBe(true)
}