Skip to content

Latest commit

 

History

History
253 lines (210 loc) · 8.82 KB

flattening.md

File metadata and controls

253 lines (210 loc) · 8.82 KB

Observable Flattening

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 Promises. Much like flattening Promises, 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 Promises (as Promises 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.


No Out-of-context Flattening

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.


Only Flatten Out-of-context Identifiers

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.


Transpilation

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() and map() 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 any a_i is not an observable, then of(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}`))