Ambient Contracts — probe reality, derive verified predicate contracts

Status: exploratory design note (written 2026-07-03, checked 2026-11-06). This describes a direction, not a feature you can use today. The contract tooling is not built: there is no probe, derive or conform step in the package, only an experimental spike (see the contributor note at the end). What you can use today is the pieces it builds on: verified predicates (verifyPredicate in tjs-lang/lang) and the CSS validators in tjs-lang/css, which shipped after this note was written (0.9.0, 2026-07-06).

Terms used throughout. An ambient object is one the environment hands your code rather than one you created: the browser's document, an event, Node's process. A predicate is a function that answers true or false about a value. A verified predicate is one TJS has checked is pure: it only reads, never changes anything and never throws. A contract is a predicate that describes what an object must look like.

The itch

Static types are pessimistic about ambient runtime environments (the DOM, Node globals, window, host objects) in ways that create ceremony without safety. The canonical example:

element.addEventListener('input', (e) => {
  const value = e.target.value // ❌ TS: Property 'value' does not exist on 'EventTarget'
})

In TypeScript, e.target is declared as a generic EventTarget | null, and EventTarget has no .value, so the compiler rejects this until you assert the type with (e.target as HTMLInputElement).value or narrow it with if (e.target instanceof HTMLInputElement). But the honest situation is: at runtime the value is either there or it isn't, and a one-line runtime check can tell you which; the cast tells you nothing. It silences a compiler that can't see the real object. A runtime predicate is the truthful tool here, and it is plain JavaScript, which is the point:

// Pure and total: it only reads properties, and it never throws.
function hasValueTarget(e) {
  return e != null && e.target != null && typeof e.target.value === 'string'
}

// Event-shaped objects, like the ones a browser hands a listener.
const inputEvent = { type: 'input', target: { tagName: 'INPUT', value: 'hi' } }
const clickOnDiv = { type: 'click', target: { tagName: 'DIV' } }

console.log(hasValueTarget(inputEvent)) // true
console.log(hasValueTarget(clickOnDiv)) // false: a <div> has no value
console.log(hasValueTarget(null)) // false, not a crash

// The usage: no cast, and no crash when the value is missing.
const e = inputEvent
console.log(hasValueTarget(e) ? e.target.value : undefined) // hi

"Pure" means it changes nothing; "total" means it gives an answer for every input. That predicate is exactly what the verifier accepts: verifyPredicate (from tjs-lang/lang) reports it safe, because member access and typeof are pure. It compiles to native JavaScript, and it is serializable: it is source text, so it can be captured in one place and used in another (see the next section).

Hand-writing a predicate for every host object you touch is tedious, and the result drifts from what the browser really does. So the thought: could tooling probe a real ambient environment and derive the predicate contracts automatically?

The payoff: author against an environment you're not running in

The headline use: type-checking and autocompletion against a non-ambient runtime — editing in a Node-based toolchain while targeting the browser (or the reverse, or Electron, or a specific Bun). Normally you have no target environment to introspect, so you fall back to a hand-maintained .d.ts (lib.dom: TypeScript's hand-written description of the browser APIs) that drifts from what the engine actually does.

Because a probed contract is a serializable predicate, it decouples capture from consumption in time and space: probe the real target once (locally or in CI), bottle the contract, and a Node-side editor/typechecker consumes it with no target present. It beats a .d.ts on three axes:

Why it's plausible here and not a research project: TJS can already embed a predicate inside a JSON Schema (the $predicate keyword, via tjs-lang/schema), so the contract format exists. The playground's autocomplete already asks a live object for its members; it could ask a captured contract instead. And a planned feature, argument-type-driven completion (for example, knowing that createElement('input') returns an input element), needs exactly this metadata, taken from reality instead of written by hand.

Contract vs. shim — the distinction that decides feasibility

"A predicate that stands in for the DOM" can mean two very different things:

  1. A contract / validator — "does this value behave enough like an HTMLElement / an input event / a CSSStyleDeclaration?" Predicates are pure validators, so this is squarely in scope: shape, property presence, method arity, pure invariants.
  2. A behavioral substitute — something that, when code calls document.createElement('div'), actually does it. Predicates cannot be this (they're pure, stateless, effect-free). That's a shim — happy-dom/jsdom territory.

The valuable, on-thesis move is #1 as the bridge to #2: probe the real environment, emit a serializable predicate contract, and use it to certify a stand-in in whatever setting. The predicate isn't the DOM — it's the portable, verifiable spec a substitute must satisfy, auto-derived from reality and from the surface a given program actually touches.

Why it fits this project

The honest constraints

Shape of the tooling (sketch)

  1. Probe — in a real environment (an automated real browser, or the introspection iframe), walk an ambient object / a program's used surface and record: property names + typeof, method names + .length (arity), and a few sampled input→output pairs for getters whose value depends only on the object.
  2. Derive — turn the probe record into a predicate cluster (shape checks + typeof guards + membership sets), run it through verifyPredicate (so the emitted contract is itself certified safe), and suggest()-mine it for the enumerable leaves.
  3. Emit — a $predicate schema (naive validators see structure, aware ones run the contract) + optionally a .d.ts-ish view for editors.
  4. Conform — a harness that validates a stand-in (happy-dom, a hand stub, a VM capability) against the contract, failing loud where it diverges. This is what lets you test code that expects HTMLElement to exist from Node, where it doesn't.

First spike — CSS + DOM (the convergence point)

Both parts of this spike were built; the results are under Findings below.

element.style (CSSStyleDeclaration) is the ideal first target: it's an object the browser provides, its contents change as styles are set, and we already have a full CSS value grammar as predicates (tjs-lang/css). So the loop is concrete and small:

Asking the browser directly: CSS.supports

CSS.supports(property, value) is a standard browser function that tells you whether the browser accepts a CSS declaration. It is the strongest form of "probe reality" for CSS: exact, always current with the engine's real support (vendor prefixes, new features, the full calc()/var() grammar), and zero-maintenance. It closes the gap the hand-written grammar has (checked 2026-11-06):

import { isCssProperty } from 'tjs-lang/css'

isCssProperty('align-kontent') // true: it only checks the name looks like a property
CSS.supports('align-kontent', 'center') // false in a real browser

Property names for autocomplete come from the same place (a throwaway element's .style keys / CSSStyleDeclaration.prototype). The tosijs 2.0 port reached the same idea independently; its notes live in the tosijs repository.

This sharpens the contract-vs-fallback split by when validation runs:

So the ideal CSS-value check (not built; tjs-lang/css has only the grammar half) prefers CSS.supports when typeof CSS !== 'undefined' and falls back to the grammar predicate otherwise — which is exactly the probe-reality / portable-fallback pairing this whole note is about, one leaf down. Caveat (verified, and it's the same "the stand-in lies" finding): happy-dom stubs CSS.supports to return true for everything (even width: banana) — so tests can't rely on it; ground truth needs a real browser or a real CSS engine.

Findings from the first spike (2026-07-03)

Built in experiments/ambient/ (probe.ts plus two demo tests, 7 tests, still green on 2026-11-06). Nothing below has moved out of experiments/ since:

Net: the probe→derive→verify→conform loop works end-to-end on a real host object; the open work is sourcing the used-surface scope (trace) and the behavioral half.

Real-browser findings (live Chrome, 2026-07-03)

Confirmed against a real browser (not happy-dom), by evaluating code in a live Chrome tab. This sharpened three points:

Generalizes beyond the DOM — Node/Bun internals

The probe is environment-agnostic: process, Buffer, node:fs, Bun.*, globalThis are ambient objects with the same shape. Derive a used-surface contract from the real runtime and certify a stand-in — e.g. "does my process usage survive in the browser polyfill?" or "does Bun's process satisfy the contract my code derived from Node's?". It's the realm/stand-in problem one level up: cross-runtime portability instead of cross-frame. Same tool.

Open questions

For contributors

The spike is experiments/ambient/: probe.ts (probeShape, deriveShapeContract), event-target.demo.test.ts and css-style-probe.demo.test.ts. Related code: the predicate engine (src/lang/predicate.ts), the autocomplete introspection bridge (demo/src/introspection-bridge.ts, editors/introspect-value.ts; the playground's getMembers hook), and the VM capability model (src/vm/).