This document specifies a domain‑agnostic, Optional‑first, functional DSL for building composable data‑flow programs over structured collections of objects that expose one or more domain values (e.g.,
Vector). The DSL is realized as a set of thin, orthogonal layers in TypeScript. It is not a string‑parsed language; its surface syntax is the fluent API you call from TypeScript. This spec defines the abstract syntax, static and dynamic semantics, type discipline, algebraic laws, and conformance requirements for any implementation.
Goals
- Generalization — The DSL works with any domain type
D(Vector, Complex, Color, Angle…) via a small DomainAdapter; no per‑method wiring. - Optional‑First — All evaluation is total and side‑effect free from the DSL’s point of view; failures map to
Optional.none. - Functional Composition — Chains, traversals, and reductions are expressed as pure folds over immutable IR steps.
- Unification — One abstraction serves maps, arrays, and matrices; traversals unify start axis and peer axis; aggregation is by Monoids.
- Thin Lingual Layers — Each concept appears once, with no imperative duplication: Optional, Algebra, IR, Adapter, Scope, Chain, Traversal.
- Ergonomics without Magic — New domain methods “just work” (captured by Proxy); scalar lifting and algebraic facts are declarative metadata on prototype methods.
Non‑Goals
- The spec does not mandate a specific rendering/UI or editor integration.
- The spec does not fix a single strict typing regime for terminal value kinds (scalar/boolean); it defines an optional typing extension.
Optional<T>is a right‑biased disjoint union with constructorssome(T)andnone<T>(). All examples assume the implementation inexternal/Funk/optional/optional.Monoid<M>is a pair(empty: M, concat: (M, M) => M). Seemath/algebra/monoid.ts.- Domain
D: the underlying class you operate on (e.g.,Vector). - Object schema
Obj: user objects that contain one or more fields of typeD(e.g.,{ position: Vector, size: Vector }). - Start axis: traversing which object acts as the current focus (A, B, C…).
- Peer axis: traversing others relative to the current start (e.g., “all except A”).
Normative terms: MUST, SHOULD, MAY follow RFC‑2119 usage.
The DSL is composed of the following layers. Each layer MUST be independent and free of knowledge belonging to layers above it.
-
Optional Layer Provides
mapOpt,chainOpt,apOpt,sequenceArrayOpt,reduceWithMonoidOpt. It MUST NOT expose or rely on.left/.rightfields; all access goes throughfold,map,chain. -
Algebra Layer Provides
Monoid<T>and standard instances:all,any,sum,product,first. Used by Traversal reducers. -
IR Layer (Abstract Syntax)
- ArgRef ∈ {
ConstD,ConstS,PropRef,OfRef,NestedExpr,Current} - Step ∈ {
Select(prop),Switch(to, key?),Invoke(op, args: ArgRef[])} - Program is a finite
Step[].
- ArgRef ∈ {
-
Adapter Layer (Domain Binding)
DomainAdapter<D>withisInstance,getMethod, optionalfromScalar, optionalmethodReturns.- Op metadata (
DSL_OP_METAon prototype methods):liftScalar(boolean|boolean[]),commutative,associative,pure.
-
Scope Layer (Environment/Navigation)
Scope<D>withgetPropOpt,getPropOfOpt,setFocusByKey,setFocusByIndex,setFocusToOther,currentKey,keys.- Implementations: MapScope, ArrayScope, MatrixScope.
All property reads return
Optional<D>.
-
Evaluator (Dynamic Semantics)
- Total function
evalProgram(adapter, steps, initial: Optional<D>, scope?): Optional<any>. - Implements switch / select / invoke semantics over
Optional.
- Total function
-
Chain Layer (Surface DSL)
BaseChain<Obj, D>with.prop(...),.self()/.other(), dynamic method section._,.build()/.value(),.traverse(),.peers().- No per‑op wiring;
._pushesInvokewith normalizedArgRef[].
-
Traversal Layer (Aggregation)
Traversal<A>with.map,.compact, boolean reducers (any/all/none), numeric reducers (sum/min/max), generic.fold(Monoid, f), and domain.reduceBy("op").
-
Mini‑DSL Facades
- Example: Vector DSL exposes
vectorExpr,vectorMapChain, … created from aDomainAdapter<Vector>.
- Example: Vector DSL exposes
ArgRef<D>::=ConstD(value: D)|ConstS(value: number)|PropRef(name: string)|OfRef(key: string, prop: string)|NestedExpr(expr: DomainExpr<D>)|CurrentStep<D>::=Select(prop: string)|Switch(to: Self|Other|Key|Index, key?: string|number)|Invoke(op: string, args: ArgRef<D>[])Program<D>::=Step<D>[]
Given a runtime argument a passed in the surface DSL:
- If
typeof a === "number"⇒ConstS(a). - Else if
ais aDomainExprproxy ⇒NestedExpr(a._expr). - Else if
Array.isArray(a)and[key, prop]are both strings ⇒OfRef(key, prop). - Else if
typeof a === "string"⇒PropRef(a). - Else ⇒
ConstD(a)(MUST be validated by adapter at invoke time).
Note: ConstD MUST NOT be used to smuggle non‑domain values; isInstance guards at invocation.
A Chain Program is well‑formed iff:
- The program contains at least one
Selectbefore the firstInvoke. Otherwise,build()/value()MUST returnnone. Switch(to=Other)without an explicit key is only defined whenscope.keys().length === 2. Otherwise, the evaluator MUST yieldnonefrom the point of use (see §6).PropRef(name)andOfRef(key, prop)refer to properties whose runtime values satisfyadapter.isInstance. Otherwise the evaluator MUST yieldnonefor that argument.- Any
Invokeapplied to a non‑domain current value MUST yieldnone.
Optional (typing extension): An implementation MAY refine methodReturns(op) to detect terminals (scalar/boolean) and reject subsequent domain invocations at compile time; when omitted, the runtime rule (4) applies.
Let ⟦Program⟧(initial: Optional<D>, scope?: Scope<D>) : Optional<any> denote evaluation.
The evaluator processes steps in order, maintaining cur: Optional<any>.
-
Switch
- Requires
scope. Self(key)sets focus tokey(string) or index (number).Other(key?)flips focus when|keys| = 2; withkeyprovided, sets explicit focus by key/index.Key(k)sets focus by key;Index(i)by index.curis unchanged.
- Requires
-
Select(prop)
- Requires
scope. cur := scope.getPropOpt(prop)(anOptional<D>).
- Requires
-
Invoke(op, args)`
-
If
curisnone⇒cur := none. -
Else let
self := cur(domain value).-
If
!adapter.isInstance(self)⇒cur := none. -
Else fetch
method := adapter.getMethod(self, op); if missing ⇒cur := none. -
Evaluate each
ArgReftoOptional<any>using:ConstD(v)→some(v)ConstS(n)→some(n)PropRef(name)→scope.getPropOpt(name)OfRef(k, p)→scope.getPropOfOpt(k, p)NestedExpr(e)→e.build(adapter)(cur, scope)Current→cur
-
Sequence all arguments via
sequenceArrayOpt. If anynone⇒cur := none. -
If method has attached metadata
meta := (method as any)[DSL_OP_META], andmeta.liftScalaris set, scalar lift each numeric arg withadapter.fromScalaraccording to the rule (see §7.2). -
Finally:
cur := some(method.apply(self, callArgs)), wrapped intry/catch(exceptions ⇒none).
-
-
Totality: Evaluation is total and returns an Optional. No throws.
-
All missing data, type mismatches, unknown methods, invalid switches, and runtime exceptions in domain methods MUST map to
none. -
Reducers (Traversal) operate over arrays of
Optional<A>. The default policy is lenient:- For
any/all/none/sum/min/max/foldwe operate over present elements, ignoringnone. (An implementation MAY expose a strict mode in which anynonecollapses the reduction tonone.)
- For
-
Surfaces (
value,traverse,peers,reduceBy) MUST NOT throw; they returnOptionalor total primitives.
interface DomainAdapter<D> {
name: string;
isInstance(v: unknown): v is D;
getMethod(self: D, name: string): ((...a: any[]) => any) | undefined;
fromScalar?: (n: number) => D; // optional
methodReturns?(name: string): ValueKind; // optional: Domain|Scalar|Boolean|Unknown
}isInstanceMUST be a precise runtime type guard; false positives are illegal.getMethodMUST returnundefinedfor unknown names.fromScalarenables scalar lifting (§7.2); if absent, numeric args remain numeric.
Each domain method MAY carry an op metadata object stored under DSL_OP_META on the function value:
type OpMeta = {
liftScalar?: boolean | boolean[]; // true → all numeric args; [b0,b1,…] → per-arg
commutative?: boolean;
associative?: boolean;
pure?: boolean;
}- Scalar Lift: When present, the evaluator MUST transform numeric args into domain instances with
adapter.fromScalaraccording to theliftScalarrule. IffromScalaris absent, lifting is a no‑op. - Commutative/Associative: These are algebraic hints; the evaluator MAY exploit them (e.g., for parallel folds). The semantics are unaffected.
A Scope<D> represents the object space and current focus.
interface Scope<D> {
getPropOpt(prop: string): Optional<D>;
getPropOfOpt(key: string, prop: string): Optional<D>;
setFocusByKey(key: string): void;
setFocusByIndex(index: number): void;
setFocusToOther(): void; // only defined when |keys|=2
currentKey(): string;
keys(): string[];
}Implementations:
- MapScope: keys are object keys.
other()flips focus only ifkeys().length === 2; otherwise the call MUST cause subsequent evaluation to becomenonefor that step’s dependency. - ArrayScope: sugar over MapScope with keys
"0".."n-1". - MatrixScope: keys are
"i,j".other()is undefined (MUST causenoneif invoked without an explicit key).
All get* accessors return Optional<D> — never throw.
mapChain<Obj, D>(adapter, map: Record<string, Obj>) : Chain<Obj,D>arrayChain<Obj, D>(adapter, arr: Obj[]) : Chain<Obj,D>matrixChain<Obj, D>(adapter, grid: Obj[][]) : Chain<Obj,D>
Chain<Obj,D> exposes:
.prop<P extends KeysOfType<Obj,D>>(name: P)— MUST be called before any method invocation..self(key?: string|number)— set focus to self (or specific key/index)..other(key?: string|number)— set focus to other (pair) or to explicit key/index.._.<op>(...args)— record anInvoke(op, normalizeArgs(args)). Works for any method name..build() : (start: string|number) => Optional<any>.value(start)— shorthand forbuild()(start)
After forming a base chain:
.traverse(expr?: DomainExpr<D>) : Traversal<R>Replays the chain for each start key in order; ifexpris provided, applies it to each base result..peers(expr?: DomainExpr<D>) : Traversal<R>Replays the chain for each peer of the current start (call.value(start)first to set focus).
-
DomainExpr<D>is created viadomainExpr<D>(), a Proxy that captures any method chain into IR steps..select(prop)pushesSelect(prop)(used when the expr needs to start from a property in scope)..identity()no‑op.- Any property access (e.g.,
.add("size").subtract(1)) is captured asInvoke.
-
expr.build(adapter) : (current: Optional<D>, scope?: Scope<D>) => Optional<any>When used inNestedExpr, the current value is the base result of the chain at that point.
Traversal<A> represents a multiset of Optional<A> results. It supports:
results(): Optional<A>[]— raw results.map<B>(f: (a: A) => B): Traversal<B>— pointwise map (Optional‑aware).compact(): Traversal<NonNullable<A>>— dropnull|undefinedvalues (keepsnoneout).- Boolean reducers:
.any(f?) : boolean(OR),.all(f?) : boolean(AND),.none(f?) : boolean(NOT any). Operate over present values (lenient policy). - Numeric reducers:
.sum(f?) : number,.min(f?) : number|undefined,.max(f?) : number|undefined. - Monoidal fold:
.fold<M>(Monoid<M>, f: (a: A) => M) : Optional<M>— ignoresnoneby default. - Domain reduction:
.reduceBy(op: string) : Optional<A>— left‑fold using binary domain methodop(e.g.,"add").
Strictness policy: Implementations MAY offer a strict variant (e.g., .foldStrict) where any none collapses the result to none.
The adapter MAY define methodReturns(op) ∈ {Domain, Scalar, Boolean, Unknown}. A typed implementation MAY:
- Track an effect type
Kfor eachDomainExpr<D, K>node. - Reject at compile time any
Invokeof domain methods after a non‑Domain terminal. - Allow
.traverse()/.peers()to be generically typed asTraversal<K>.
This is optional and MUST NOT be assumed by reducers (runtime semantics remain total).
The language has four orthogonal axes:
- Property axis —
Select(prop)picks aDfrom the current object. - Start axis —
.traverse()iterates all starting keys. - Peer axis —
.peers()iterates all others relative to the current start. - Branch axis (optional extension) —
fork(expr1, expr2, …)splits into multiple expressions applied to the same base value; subsequent invocations broadcast to branches;.values(start)returnsOptional[].
Composability: Axes MUST commute where semantics are unambiguous. For example, applying an expr inside .traverse() is equivalent to post‑mapping the traversal (.traverse().map(applyExpr)), modulo Optional strictness.
When invoking a domain method:
- If method carries
liftScalar: true, every numeric argumentnMUST be transformed toadapter.fromScalar(n)prior to invocation, iffromScalaris defined. - If
liftScalar: [b0, b1, …], only arguments at indices withtruevalues are lifted. - If
fromScalaris absent, lifting is a no‑op (numeric args remain numbers).
Provided domain methods respect the advertised metadata:
- Commutativity:
a.add(b) = b.add(a)if methodaddis marked commutative. - Associativity:
(a.add(b)).add(c) = a.add(b.add(c))if associative. - Purity: If
pure, repeated evaluation with the same inputs must yield the same outputs.
These laws enable safe reordering or parallelization of .reduceBy and .fold but are advisory; breaking them violates the conformance of adapter metadata, not the evaluator.
An implementation conforms to this specification if:
- It implements layers (§2) such that each layer is independent and composable.
- It implements IR and evaluation rules (§3–§6) exactly, including totality and Optional semantics.
- It honors adapter and op metadata semantics (§7).
- It implements scopes with Optional accessors (§8).
- Its chain surface constructs only push normalized IR steps (§9.1–§9.3).
- Its traversals implement lenient reducers by default (§10); strict variants MAY be provided explicitly.
- The evaluator MUST catch exceptions thrown by domain method invocations and treat them as
none. - Domain methods MAY be impure; however, if a method is annotated
pure: true, the adapter author warrants referential transparency under the DSL’s usage.
- Memoizing compiled expressions (
DomainExpr.build(adapter)) by a stable encoding ofStep[]MAY be implemented usingUnitalOptionalMap. - Caching MUST NOT alter semantics; it is a performance optimization only.
const sep =
vectorMapChain({ A, B })
.prop("position")._.add("size").other()._.subtract("position")
.traverse( vectorExpr().anyNonPositive() )
.any(); // booleanSemantics:
- For each start (
"A","B"), evaluateposition + size - other.position⇒Optional<Vector>. - Apply
anyNonPositive()per result ⇒Optional<boolean>. any()OR‑reduces over present booleans ⇒boolean.
const minDist =
vectorMapChain({ A, B, C, D })
.self("A").prop("position")
.peers( vectorExpr().subtract("position").length() )
.min(); // number | undefined (lenient policy)const sumOffsetsOpt =
vectorMapChain({ A, B, C })
.self("A").prop("position")
.peers( vectorExpr().subtract("position") )
.reduceBy("add"); // Optional<Vector>This EBNF reflects fluent TS calls, not a textual grammar.
Chain ::= MapChain | ArrayChain | MatrixChain ;
MapChain ::= "mapChain" "(" Adapter "," Map ")" "." Pipeline ;
ArrayChain ::= "arrayChain" "(" Adapter "," Array ")" "." Pipeline ;
MatrixChain ::= "matrixChain" "(" Adapter "," Matrix ")" "." Pipeline ;
Pipeline ::= Select ( Nav | Invoke )* ( Build | Traverse | Peers ) ;
Select ::= "prop" "(" PropName ")"
Nav ::= "self" "(" [ KeyOrIndex ] ")" | "other" "(" [ KeyOrIndex ] ")"
Invoke ::= "._." OpName "(" ArgList ")"
Build ::= "build" "(" ")" | "value" "(" Start ")"
Traverse ::= "traverse" "(" [ Expr ] ")" "." Reducer
Peers ::= "peers" "(" [ Expr ] ")" "." Reducer
Expr ::= "domainExpr" "<" D ">" "(" ")" "." ( "select" "(" PropName ")" | OpName "(" ArgList ")" )*
ArgList ::= [ Arg ( "," Arg )* ]
Arg ::= Number | String | "[" String "," String "]" | Expr
Reducer ::= "any" "(" [ Pred ] ")" | "all" "(" [ Pred ] ")" | "none" "(" [ Pred ] ")"
| "sum" "(" [ NumProj ] ")" | "min" "(" [ NumProj ] ")" | "max" "(" [ NumProj ] ")"
| "fold" "(" Monoid "," Proj ")"
| "reduceBy" "(" String ")"
- Lenses: Replace
prop(string)with typed lenses for nested access (Lens<Obj, D>). Semantics unchanged (§5: Select uses lens get). - Strict Traversal: Add
.strict()on Traversal to switch reducers into “any none ⇒ none” mode. - Typed Op Names: Adapter may expose a string‑literal union of known ops to type
.reduceByarguments. - Branch Axis: Add
fork(...exprs): MultiChain, broadcasting subsequent invokes and returningOptional[]via.values(start); semantics are a map overexprswith shared base value.
- No
.left/.rightused outside Optional’s own implementation. -
normalizeArgsproduces only IR categories in §3.2. -
evalProgramimplements §5 exactly; total and Optional‑first. - Scalar lift honored according to metadata and
fromScalar. -
other()without explicit key yieldsnoneunless pair scope. - Traversal reducers lenient by default; strict variant (if present) opt‑in.
- Chain’s dynamic
._pushes exactly oneInvokewith normalized args (no hacks). - DomainAdapter is precise and side‑effect free.
- Domain: The concrete class
Dthe DSL operates on (e.g.,Vector). - Schema: The object structure hosting domain values (
Obj). - Start: The focused object (by key or index) for which a chain evaluates.
- Peer: Any object in the same collection except the current start.
- Lift: Converting numeric arguments into domain values via
fromScalar.
- v0.9 — Initial comprehensive specification (Optional‑first; adapters; scopes; IR; traversals; algebra; metadata).
Backward‑compatible changes include: adding new reducers, new metadata fields (ignored by older impls), new traversal policies gated by explicit opt‑in.
Let C be a chain ending in a base value b: Optional<D> for a given start.
C.traverse(E).map(F)≡C.traverse((x) => F(E(x)))C.peers(E).map(F)≡C.peers((x) => F(E(x)))- If
opis associative,reduceBy(op)is invariant to parenthesization.
Goal: inclusive coverage rectangle → grid
p1 = floor(position / cell)
p2 = floor((position + size - 1) / cell)
DSL:
const cell = Vector.scalar(cellSize);
const gridBounds =
vectorMapChain({ b })
.prop("position")
.traverse(
vectorExpr().select("position") // optional; base is already position
); // returns Traversal<Vector>
const [p1Opt, p2Opt] =
vectorMapChain({ b })
.prop("position")
.peers( // use branch axis alternatively via fork extension
vectorExpr().identity(),
).results(); // illustration only
// Canonical with fork (extension):
// position → [position, position + size - 1] → divide(cell) → floor()Semantics: see §5 and §10.