The core idea of the proposed solution is syntactically flattening observable values, i.e. making values wrapped inside an observable accessible for use within expressions, similar to how await
keyword flattens Promise
s. Much like flattening Promise
s, flattening observables MUST be restricted to explicitly specified contexts (read this for more details), for which we can follow the example of Promsie
flattening:
const a = makeSomePromise(...)
const b = (await a) + 2 // ❌ syntax error
const b = async () => (await a) + 2 // ✅ this is ok, the expression is now enclosed in an async context
We could use a similar syntax for specifying contexts where flattening observables is possible (observable context):
@ => <Expression>
@ => {
<Statement>
<Statement>
...
}
❓ Why this syntax? Why not a new function modifier like
async
?The goal here is to have expressions of observables, i.e. observable expressions, which are observables themselves. A function modifier would imply a function that returns an observable when called, not an observable itself. Though such deferrence is useful for
Promise
s (asPromise
s are eagerly evaluated while async functions are lazy), it is not useful for observables as observables themselves are lazy.
Within the context of the expression (or statements) following @ =>
we could now use a flattening operator, similar to await
, for accessing values within observables:
const a = makeSomeObservable(...)
const b = @ => @a + 2
❓ Why this syntax? Why not a keyword like
await
?A keyword such as
await
would mean the flattening operator could also operate on expressions. For async expressions, such inner-expressions won't cause semantic ambiguity, since the whole expression, including the inner expression, is evaluated ONCE. Observable expressions, however, are evaluated potentially many times, which would result in the inner expression also being evaluated each time, resulting in a new observable the expression is dependent on, which is NEVER the intended behavior.
The flattening operator @
MUST be limited to observable contexts, and usage outside of such contexts needs to be a syntax error:
const a = makeSomeObservable(...)
const b = @a + 2 // ❌ SYNTAX ERROR!
Read this for more details on why. In short, out-of-context usage creates semantic ambiguity about the boundaries of the observable expression, e.g. for the following code:
console.log(@a + 2)
It cannot be determined whether an observable (@a + 2
) should be logged ONCE, or whether new values of a
(plus 2) should be logged each time a
has a new value. Furthermore, without explicit disambiguation, leaning either way would either result in a semantic contradiction or violate some essential syntactic invariance.
The flattening MUST BE limited to identifiers defined outside the current observable context:
// ❌ SYNTAX ERROR!
const b = @ => @makeSomeObservable(...) + 2
This is because observable expressions yield dependent observables, i.e. observables that emit new values whenever the source observables they depend on emit values. So for example in the following case:
const a = makeSomeObservable(...)
const b = makeSomeObservable(...)
const c = @ => @a + @b
c
has a new value every time a
or b
have new values, and this new value is calculated by re-evaluating the observable expression that is defining c
. However, in our previous, erroneous case, the observable b
is dependent on changes every time the source observable has a new value, as the dependency is described using an expression within the observable expression itself. The limit to identifiers defined outside of the current context ensures that dependencies are stable and don't change with each re-evaluation of the observable expression.
An exception to this rule would be chain-flattening:
// What we want to do:
// start a new timer whenever some button is clicked,
// and display the value of the last timer.
const click = observableFromClick(...)
const timers = @ => makeAnObservableTimer(...)
const message = @ => `Latest timer is: ${@@timers}`
Here, timers
is an observable whose values are observables themselves, so @timers
is still an observable. @@timers
can unambiguously be resolved to the latest value of the latest observable emitted by timers
, which means message
is still only dependent on timers
and its dependencies do not get changed with every re-evaluation.
The proposed solution is merely syntactical: in other words it DOES NOT enable new things to do, it only enables rewriting existing code in a simpler, more readable manner. Generally, for any expression E
with n free variables, and a_1
, a_2
, ..., a_n
being identifiers not appearing in E
, the following schematic code of the new syntax:
@ => E(@a_1, @a_2, ..., @a_n)
Could be transpiled to:
combineLatest(a_1, a_2, ..., a_n)
.pipe(
map(([_a_1, _a_2, ..., _a_n]) => E(_a_1, _a_2, ..., _a_n))
)
In other words, the expression yields a new observable that whenever either one of a_1
, a_2
, ..., a_n
have a new value, then E
is re-evaluated with all of the latest values and the resulting observable assumes (emits) the resulting value. E
can also be a function body, i.e.
{
<STATEMENT>
<STATEMENT>
...
[return <EXPRESSION>]
}
where @a_1
, ..., @a_n
appear in the statements and the optional return expression, with the same transpilation.
💡 On Transpilation
For semantic clarity, in these examples, behavior of
combineLatest()
andmap()
from RxJS is assumed, though for an actual implementation, pehraps more efficient and light-weight libraries can be utilized. There is also an implicit assumption here that is not mentioned in the examples for sake of maintaing code clarity: if anya_i
is not an observable, thenof(a_i)
is used for the transpilation. This can be conducted with a simple normalizing operator where all arguments are passed through it:const normalize = o => (o instance of Observable) ? o : of(o)
Example
// Proposed syntax:
const a = interval(100)
const b = @ => Math.floor(@a / 10)
// Transpilation:
const a = interval(100)
const b = combineLatest(a).pipe(map(([_a]) => Math.floor(_a / 10)))
Another Example
// Proposed syntax:
const a = interval(100)
const b = @ => {
const seconds = Math.floor(@a / 10)
const centi = @a - seconds
return `${seconds}:${centi}`
}
// Transpilation:
const a = interval(100)
const b = combineLatest(a).pipe(
map(([_a]) => {
const seconds = Math.floor(_a / 10)
const centi = _a - seconds
return `${seconds}:${centi}`
})
)
Another Example
// Proposed syntax
const a = interval(100)
const b = interval(200)
const c = @ => @a + @b
// Transpilation:
const a = interval(100)
const b = interval(200)
const c = combineLatest(a, b).pipe(map(([_a, _b]) => _a + _b))
Another Example
// Proposed syntax
const a = interval(100)
const b = interval(200)
const c = @ => {
console.log('New Value!')
return @a + @b
}
// Transpilation:
const a = interval(100)
const b = interval(200)
const c = combineLatest(a, b).pipe(
map(([_a, _b]) => {
console.log('New Value!')
return _a + _b
})
)
For transpilation of chain flattening, we can flatten the observable before combining it with other observables, i.e. we could transpile the general form:
@ => E(@@a_1, @a_2, ..., @a_n)
to:
combineLatest(a_1.pipe(switchAll()), a_2, ..., a_n)
.pipe(
map(([__a_1, _a_2, ..., _a_n]) => E(__a_1, _a_2, ..., _a_n))
)
With the same transpilation for any arbitrary a_i
. Additionally, for larger flattening chains, we just need to further flatten the observable, i.e. turn the following:
@@@@a_5
to:
a_5.pipe(switchAll(), switchAll(), switchAll())
Example
// Proposed syntax:
const click = fromEvent($btn, 'click')
const timers = @ => { @click; return interval(1000) }
const message = @ => `Latest timer is: ${@@timers}`
// Transpilation:
const click = fromEvent($btn, 'click')
const timers = combineLatest(click).pipe(map(([_click]) => { _click; return interval(1000) })
const message = combineLatest(timers.pipe(switchAll())).pipe(map(([__timers]) => `Latest timer is ${__timers}`))