Replies: 3 comments 17 replies
|
Yeah I see that it may seem more elegant not having to using the call syntax. It doesn't seem to conflict with any other TC39 proposals. But at the same time if we take Solid, when I use I def see that the current lazy This syntax does make Solid look like React which may make it easier for people migrating from React to Solid to digest the syntax without caring how Solid works. So, since it's an opt-in and essentially just a binding-level sugar for zero args calls, I don't mind having this in the tsrx syntax. I guess my concern would be if we wanted to use |
It is the Svelte 5 issue. Because This does not adopt React’s execution model. const [&count, setCount] = createSignal(0);
const &double = () => count * 2;This captures one snapshot: const [&count, setCount] = createSignal(0);
const double = count * 2;That can be intentional, so the language cannot reject it universally, but Solid-aware tooling could warn about likely accidental untracked reads. The opt-in binding also gives Solid tooling a definitive symbol to trace. It can diagnose eager computations such as const double = count * 2 in component initialization without relying on API-name inference. Solid could enforce it through a strict diagnostic with an explicit snapshot boundary.
Yeah I was thinking that too this morning. I've editing the proposal to explore some of those options. |
|
I'm a bit torn on this proposal. Whilst I can see why you'd want it, it poses a few hesitations from me:
|
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Call-through bindings
Status: design draft
Summary
Add an explicit binding form for values that should be invoked whenever the
binding is read:
This proposal calls
&counta call-through binding. It is not specific tosignals or to any framework. It represents a zero-argument callable whose result
is exposed through ordinary reads of the local binding.
At runtime, each read invokes the callable. For static analysis, a read is treated
as an ordinary zero-argument call at that source location, including contextual
typing, overload resolution, and widening.
Conceptually, the example above lowers to:
Motivation
TSRX lazy object and array patterns defer property and index selection:
They intentionally do not invoke selected values. Consequently, lazy array
destructuring does not make a tuple containing an accessor convenient:
Automatically invoking functions selected by
&[]would be unsafe. Setters, eventhandlers, components, and ordinary function-valued data are functions too.
Invocation therefore needs to be expressed by the individual binding.
Call-through bindings also apply to APIs exposing accessors through objects,
standalone accessors, callback parameters, and target-specific control-flow
binders:
Proposed syntax
Extend binding atoms with a call-through identifier:
In the initial version, a call-through identifier may appear:
constvariable binding;constfor-ofbinding;It is not a general replacement for every ECMAScript or TypeScript
BindingIdentifier.The whitespace-sensitive spelling is
&value.&must be lexically adjacent tothe identifier; whitespace, comments, or a line terminator between them make the
form invalid in a binding position. This matches the existing treatment of
&{and
&[.Parser and AST model
The parser should recognize
&identifieronly while parsing a binding atom. An&identifiertoken sequence in expression position retains ordinary ECMAScriptparsing behavior and never becomes a call-through read or escape.
The smallest ESTree-compatible representation is an
Identifierbinding nodeaugmented with
callThrough: true, parallel to the existinglazy: trueaugmentation on object and array patterns. The runtime transform must consume the
flag before ordinary JavaScript is printed.
This remains semantically distinct from a lazy object or array pattern:
member-read and member-write behavior.
read-only.
Composition with lazy patterns
The existing lazy-pattern marker and a call-through leaf express separate
operations and may be composed:
The outer
&{}or&[]defers selection from the source. The inner¤tor&firstinvokes the selected callable on each read.Conceptually:
The detached call is intentional: a callable obtained through a binding is invoked
without the lazy-pattern source as its receiver. Ordinary ECMAScript
thiscoercion still applies inside the called function.
For a mixed pattern:
sourceis captured once. Everycurrentread performs one propertyGetandone detached call; a getter for
source.currenttherefore runs once per read.valueretains the existing lazy member-read and member-write behavior, whilecurrentis read-only. A computed key expression on a lazy path is reevaluated atthe point of each member selection, in path order, as part of that read.
Runtime semantics
Initialization
The initializer and ordinary destructuring operations run exactly as they do for
the corresponding non-call-through binding. The resulting callable is stored once.
Conceptually:
An enclosing lazy pattern remains lazy according to its existing semantics:
Conceptually retains the tuple and selects index
0for each read rather thanextracting it during initialization.
Reads
Every runtime reference to a call-through binding performs one zero-argument call.
Calls are not memoized or shared:
Conceptually:
Each runtime read is also a distinct call for control-flow analysis. Narrowing one
read does not narrow a later read:
Users who need one stable, narrowable result capture it explicitly:
The rewrite applies in property access, object shorthand, JSX expressions,
closures,
typeof, and other ordinary read positions:Lexical shadowing stops the rewrite:
Control-flow narrowing
Call-through bindings do not change ordinary JavaScript control-flow analysis.
Plain
if, ternary, and logical expressions see ordinary calls and require anexplicit snapshot when later reads must stay narrowed.
Compiler-owned TSRX control-flow constructs may provide stronger, construct-local
narrowing by preserving the value that admitted a branch. This is separate from
the call-through binding itself and must be implemented by the target's control
flow lowering.
For example, Solid can lower a bare call-through condition:
through
Show's narrowed accessor:Source references to
userin the admitted branch map to the narrowed accessor.This preserves reactive reevaluation, non-null typing, and stale-read protection
without treating every call-through read as globally stable. Bare truthiness
conditions are sufficient for the initial integration; compound predicates and
discriminant narrowing require separate control-flow design.
Callable current values
The binding denotes the accessor's current result, including when that result is
itself callable. Calling the binding therefore invokes both layers:
Conceptually:
This distinction is why a call-through binding should not special-case references
that happen to be the callee of another call.
Defaults
Defaults select the callable before call-through behavior is applied:
The default follows ordinary destructuring rules: it is evaluated only when the
selected value is
undefined, and the resulting callable is then invoked for eachread of
value.Under an enclosing lazy pattern, each read performs member selection, compares the
selected value with
undefined, evaluates the default when needed, and invokesthe result. A lazy default may therefore execute more than once:
Preserving this behavior requires the lazy transform to retain defaults on its
generated read path. The current implementation's loss of nested lazy defaults is
not adopted as language semantics.
Defaults do not normalize
null. A nullable carrier throws when read unless theauthor normalizes it explicitly:
Writes
Call-through bindings are read-only. It is a compile-time semantic error for any
assignment target to resolve to a call-through binding, including simple,
compound, logical, destructuring, update, and
for-inorfor-ofassignmenttargets. Declaration and per-iteration binding initialization are not assignments
to the source-level binding.
Mutation should remain explicit through the API that accompanies the accessor:
This differs from an ordinary lazy-pattern leaf, which may support write-through
member assignment. A zero-argument callable has no standard write protocol, so
this proposal does not infer one from a neighboring setter or API shape.
Broader examples
Accessor tuples
Accessor properties
Derived functions
Iterating accessors
Each iteration binds one callable. Reads of
currentinvoke the callable selectedfor that iteration.
Imported accessors
Local call-through aliases could make accessor exports convenient without changing
the module's public contract:
A direct import-binding spelling such as the following is deliberately left for
discussion:
It can lower cleanly to an ordinary hidden import, but it expands the proposal
into module grammar and requires formatter, linter, and language-service support
at another syntax site.
Function-valued state
Lazy selection plus invocation
Target-provided accessor binders
A target whose control-flow primitive supplies an accessor could allow the same
binding form in that construct:
These examples are target-gated; they do not require every TSRX target to
represent loop items or errors as accessors. Each target-defined binder specifies
a carrier type. The call-through form is valid only when that statically exposed
carrier type is zero-argument callable. Targets must not add runtime callability
gates.
Static semantics
A conforming implementation should:
no reference receiver. A
this: voidsignature is compatible; a callablerequiring another explicit
thistype is not.location, preserving contextual generic inference, overload resolution, and
widening.
type system independently supports stable-call semantics for the carrier.
non-callable constituent, or a callable that requires arguments.
No eager runtime callability check is inserted. If runtime selection produces a
non-callable value, the read throws the ordinary ECMAScript
TypeError.The split between the callable's storage type and the source binding's read type
requires language-service support. Parser and compiler support alone would leave
editor types misleading.
Call-through syntax affects only an implementation's local binding. In emitted
declarations,
&is erased and a public parameter retains its carrier type:Type-position references do not perform runtime calls. A type query for the local
call-through binding denotes the result of an uncontextualized zero-argument call;
a generic carrier may therefore produce its default or
unknownresult there.TypeScript projection
The current type-only treatment of lazy patterns cannot be reused unchanged.
Removing
&from a lazy pattern gives TypeScript a useful eager-destructuringapproximation, but removing
&from a call-through binding would typecountas() => Tinstead ofT.The virtual TypeScript projection should model storage as a callable and every
source-level read as a call expression:
Source mappings should map the authored
&countbinding to the callable storageand each authored read to the corresponding call result. Hover and diagnostic
requests on a read expose that call site's result type, while signature help and
initializer diagnostics still see
_countas the carrier.Because the call expression remains at its use site, contextual generic inference
is preserved:
Parameters and construct-owned bindings need equivalent scope-local projections:
This projection requires explicit source-segment mappings; merely teaching the
parser and runtime transform about
callThroughis insufficient.Initially excluded forms
Mutable variable declarations
Variable declarations containing call-through bindings should initially require
const:An ordinary assignment could ambiguously mean replacing the underlying callable or
writing its current result. Neither behavior is implicit in this proposal. Runtime
function parameters and
constfor-ofbindings remain valid, but laterassignments targeting them are still errors.
let,var,for-in, andassignment loop heads do not introduce call-through bindings.
Object shorthand
Call-through object bindings should initially require an explicit property:
The second form would need to define whether
valueis both the property key andthe local call-through binding, as well as custom handling before the parser
reaches a binding atom. The explicit form has ordinary object-pattern key
semantics.
Rest bindings
Call-through rest bindings should initially be rejected:
A rest operation creates a collection, not one callable binding, so invocation
semantics are unclear.
Exported bindings
Transparent call-through behavior cannot be preserved through an ordinary
ECMAScript named export:
An importing module would receive either the callable or one evaluated value; it
would not inherit the source module's lexical read rewriting. Exporting the
accessor explicitly remains available:
An export list containing an existing call-through binding should be rejected for
the same reason:
An export default expression is an ordinary read and exports one evaluated
snapshot:
Direct import bindings
The initial syntax does not include call-through import aliases:
Users can import the callable normally and create a local call-through alias. A
direct form can be considered separately after the module grammar and language
service behavior are understood.
Other binding positions
The initial version rejects call-through syntax in:
catchbindings;usingandawait usingdeclarations;These positions either have no runtime body in which reads can be rewritten or
carry declaration and initialization semantics outside this proposal. A
target-defined
@catchbinder is separate from a nativecatchclause and mayexplicitly opt in when its carrier type is callable.
JSX component names
A JSX component name is not a call-through read in the initial version:
Selecting a component reactively requires target-specific dynamic-component
lowering, not merely one call during element creation. Targets should use their
existing explicit dynamic-component facility, where
Pageappears in an ordinaryexpression position:
For Solid, this preserves the established reactive behavior of
Dynamicwhilestill allowing the call-through prop expression to expose the current component.
Call arguments
This proposal only covers zero-argument invocation. Parameterized selectors remain
ordinary functions:
Encoding arguments into a binding declaration would introduce a substantially
different feature.
Direct
evalDirect
evalin a scope containing a call-through binding should be rejected:Dynamically parsed source cannot participate in lexical read rewriting, and hidden
storage names are intentionally not observable. Indirect
evalhas no access tothe local binding and is unaffected.
Access to the underlying callable
Ordinary reads expose the callable's result, so some programs will still need the
underlying callable identity for subscription, registration, or interop.
The minimal proposal uses an explicit alias:
Possible future escape syntax could expose the callable through the call-through
name, but this draft does not propose one. Deferring the escape keeps expression
grammar unchanged and avoids committing to syntax before real identity-sensitive
cases are collected.
Syntax opportunity cost
&identifieris not entirely unclaimed syntax. TSRX already associates&in abinding position with lazy or indirect access through
&{}and&[], so a leafform belongs naturally to that family. Several other features could nevertheless
compete for the same spelling.
Scalar lazy or computed bindings
This could mean that the initializer expression is reevaluated whenever
totalisread. It is the strongest competing interpretation because it appears to be the
scalar analogue of a lazy pattern. It differs fundamentally from this proposal:
call-through initialization evaluates the initializer once, stores the resulting
callable, and invokes that callable on each read.
A computed binding would also need target-specific answers about tracking,
memoization, exceptions, and evaluation ownership. Call-through requires none of
those mechanisms; its runtime operation is an ordinary detached JavaScript call.
Writable reference bindings
This could preserve an assignable location and make later reads and writes operate
through it. Such a feature would align with the write-through behavior of ordinary
lazy-pattern leaves, but JavaScript initializers normally produce values rather
than persistent references. Receiver lifetime, computed-key reevaluation,
aliasing, and parameter-passing rules would all require new semantics.
The initial call-through proposal excludes
letandvar, but accepting&identifierfor call-through still establishes the meaning of the leaf syntaxand makes a later incompatible reference interpretation less coherent.
Generic dereference bindings
Rather than specifically invoking a callable,
&could select a genericdereference protocol that supports functions,
.valuecells, or target-definedreference objects. This would be more extensible but would make identical source
depend on carrier type or compilation target. The call-through proposal instead
chooses one operation with existing ECMAScript semantics: zero-argument
invocation.
By-reference parameters
Other languages use similar syntax for pass-by-reference parameters. Supporting
call-through formal parameters consumes that interpretation directly. TSRX would
need a new call-site and location model to support true by-reference arguments,
making this less aligned with JavaScript than call-through parameters.
Expression-position escape
An expression such as
&countcould eventually expose the underlying callable orrepresent an address-of operation:
This proposal does not consume expression-position
&identifier; it is recognizedonly while parsing a binding atom. An escape can therefore be considered
separately, although reusing the sigil should remain conceptually consistent with
the binding form.
Rationale for consuming the leaf form
Call-through is the most framework-neutral candidate:
target-specific APIs; and
The principal trade-off is giving up the concise scalar-computed interpretation.
That cost should be accepted explicitly before implementation rather than treating
&identifieras free syntax.Alternatives
Global getter-style narrowing
An implementation could treat repeated call-through reads like repeated property
getter accesses:
That is attractive for accessor control flow, but a synthetic-getter projection
changes TypeScript behavior for generic zero-argument functions by losing use-site
contextual inference. It also grants an optimistic stability assumption that
cannot be justified for every callable accepted by this general proposal.
The initial version therefore retains ordinary call-expression control flow.
Target-owned constructs such as Solid's
@iflowering may provide localnarrowing, and a future TypeScript facility for
stablecalls couldextend narrowing without changing call-through projection.
Invoked binding elements
This makes invocation visible, but places call-expression-shaped syntax in a
binding pattern. It also does not naturally provide a standalone declaration or
parameter form without spellings such as
const count() = accessor.@value@already introduces TSRX render blocks and control flow, and TypeScript uses itfor decorators, including legacy parameter decorators. Reusing it would create
both conceptual and parsing conflicts.
Automatic invocation in lazy array patterns
Inferring that
countshould be invoked from its tuple position istarget-specific and unsafe for setters and ordinary callable data. Existing
&[]semantics must remain deferred index access only.
Target-specific signal recognition
A compiler could recognize selected APIs such as
createSignal, but aliases,wrappers, user-defined factories, and cross-package APIs make callee-name
recognition incomplete. It would also move a framework-neutral binding facility
into individual target compilers.
Questions for discussion
constdeclarations,constfor-ofbindings, runtime formal parameters,and explicit target binders the right complete initial surface?
explicit nullish normalization remain required?
model?
narrowing before TypeScript supports stable calls?
&identifierleaf, or should thatsyntax be preserved for computed or writable-reference bindings?
Prototype plan
Before requesting adoption:
confirm expression-position
&identifierremains unaffected.member access and lazy writeback.
mappings.
TextMate grammar coverage.
defaults, function-valued results, nested lazy patterns, mixed
writable/read-only leaves, evaluation order, and shadowing.
direct imports, unsupported binding positions, JSX component names, direct
eval, non-callable types, and expression-position&identifier.contextual generic inference, overload and widening behavior, independent-call
control flow, target-local narrowing, diagnostics, completion scopes,
declaration emission, and source mappings.
All reactions