Skip to content

9. Expressions, Operators, and Readable Sugar

FlowScript is at its most familiar when the nodes seem to disappear:

let urgent = production && (attempts >= 3)
let status = urgent ? "critical" : "investigate"
let summary = `${status}: ${report}`
incident.status = status

This looks like ordinary code. It is also a compact description of a graph containing an Integer Greater Than or Equal node, Boolean And, Select, String Format, and Set Field. Apply does not pass these expressions to a hidden JavaScript engine. It resolves each one against the catalog and creates or updates the corresponding nodes, pins, values, and wires.

That distinction gives us the rule for every convenience in this chapter:

FlowScript may make a Flow shorter to read, but it may not make the Flow less exact.

If a short form can reproduce the complete node contract, the renderer may use it. If meaningful configuration would disappear, the explicit call remains. Readability is valuable because it helps people reason about the system; it is not permission to conceal a decision.

Release check: Arithmetic, comparison and Boolean lowering, unary ! and -, the four compound assignments, value-selecting ternaries, template literals, and Struct field writes are implemented in the current repository. Mixed known operand types are rejected. The current diagnostic does not yet offer the two intent-specific conversion repairs proposed in Section 9.1. Operator and template rendering already preserve configured extra pins in the cases described below, but three current asymmetries remain: Float equality and inequality can render as operators that text cannot reconcile, Integer division’s declared operator result disagrees with its live node output, and a Struct Get found output can be mistaken for field-value sugar. Those are implementation gaps, not part of the language contract.

9.1 Arithmetic, comparison, and Boolean expressions

Section titled “9.1 Arithmetic, comparison, and Boolean expressions”

An operator is a catalog lookup with two extra clues: the symbol and the operand type. Given:

let urgent = attempts >= 3

FlowScript knows that attempts is an Integer input and 3 is an Integer literal. It can therefore select the Integer Greater Than or Equal node and connect its Boolean output to the next consumer. The source spelling is compact; the Board still shows the operation that made urgent.

The current operator families are intentionally specific:

ValuesShort forms that currently lower to catalog nodes
Integer==, !=, >, >=, <, <=, +, -, *, %, **
Float>, >=, <, <=, +, -, *, /, **
String==, !=, +
Boolean==, &&, ||, ^

The table is narrower than the parser’s vocabulary. A token being syntactically recognizable does not prove that the active catalog contains an unambiguous node contract for it. Apply is where the symbol, types, and live catalog meet.

The parser also recognizes |, but current reconciliation has no operator node mapping for it. === and !== are accepted compatibility spellings and normalize semantically to == and !=; FlowScript does not define a separate JavaScript-style identity comparison. Integer / is omitted from the table because the current operator registry expects an Integer result while the live Integer Divide node returns a Float. Until those declarations agree, use the explicit catalog operation and inspect its Float output rather than relying on int / int sugar.

Float equality and inequality demonstrate why that matters. Comparing floating-point values usually needs a tolerance. Flow-Like’s Float Equal node exposes that tolerance as an input, so a == b cannot honestly express its full decision. Use the explicit call and choose the tolerance:

const closeEnough = float::equal({
float1: measured,
float2: expected,
tolerance: 0.001,
})

The longer form is better here. It puts an operationally meaningful assumption in the source and on the node instead of inheriting an invisible global default.

Precedence is familiar; canonical source is explicit

Section titled “Precedence is familiar; canonical source is explicit”

FlowScript reads binary expressions with the usual precedence groups. From low to high they are Boolean OR, Boolean AND, |, then ^, equality, ordering comparisons, addition and subtraction, multiplication/division/modulo, and exponentiation. Exponentiation associates to the right; the others associate to the left.

The parser can therefore understand:

let urgent = production && attempts >= 3 || manuallyEscalated

The canonical renderer adds parentheses around nested binary expressions:

let urgent = (production && (attempts >= 3)) || manuallyEscalated

That is not a semantic change. It makes the tree—and consequently the chain of operator nodes—obvious in a review. Parentheses cost very little compared with an incident caused by two readers remembering precedence differently.

Types decide; FlowScript does not guess intent

Section titled “Types decide; FlowScript does not guess intent”

The same + symbol can identify Integer Add, Float Add, or String Concat. FlowScript chooses only when the operand evidence gives it one meaning. Two strings concatenate. Two integers add. Two floats add. An Integer literal may adopt the Float family when the other operand is known to be a Float, which makes ratio * 2 natural. Two separately typed Integer and Float values remain different contracts and require an explicit conversion.

Consider the deceptively small expression:

let result = "5" + 1

There are at least two plausible results:

  • numeric addition after parsing the left side: 6; or
  • text concatenation after formatting the right side: "51".

Silently inserting a conversion would make the program compile by choosing a business decision the author never made. Current reconciliation instead reports incompatible String and Integer operands and creates no String Concat node. Apply is atomic: a reconciliation diagnostic prevents the edit commands from being applied, so the failed expression cannot leave half of a repair on the Board.

The right repair depends on intent. Numeric intent can be made explicit with a typed parser and its success output:

const { integer: parsed, success } = "5".toInt({ fallback: 0 })
if (!success) {
// Handle invalid input as part of this Flow's domain policy.
}
let total = parsed + 1

Text intent can be made explicit with formatting:

let text = `5${1}`

The desired editor experience is: reject the ambiguous Apply, offer both repairs, preview the node each would insert, and let the author submit a second Apply for the chosen repair. Canonical source then rewrites to that visible choice. The editor should not choose one automatically. A conversion can fail, truncate, select a fallback, or alter representation; its policy belongs in the Flow.

The same principle applies to any. An any + 1 expression has no reliable operator family. Narrow it through a typed interface, use a typed parser such as string::toInt, or place types::tryTransform and consume its success result. Try Transform is a real catalog node whose Generic output adapts to its typed consumer; it is not an invisible coercion rule attached to every operator. An untyped or unconnected Try Transform output produces null and success = false; a failed conversion is an ordinary result rather than a node error. Ignoring success merely moves the eventual failure to a later typed consumer.

FlowScript supports two prefix conveniences:

let unavailable = !healthy
let debt = -balance

Boolean negation becomes the Boolean Not node. Unlike binary operators, canonical source will generally show that catalog call rather than preserve !healthy. Numeric negation canonicalizes to subtraction from zero:

let debt = 0 - balance

The reconciler then chooses Integer Subtract or Float Subtract from balance’s type. A negative literal such as -3 remains a literal; an expression such as -balance becomes an operation. This small difference is visible on the Board because one needs a node and the other does not.

Four compound assignments are accepted while writing:

attempts += 1
remaining -= consumed
scale *= 2.0
scale /= 4.0

Canonical rendering expands them:

attempts = attempts + 1
remaining = remaining - consumed
scale = scale * 2.0
scale = scale / 4.0

This expansion exposes the actual model. The right side reads the current producer, the operator creates the next value, and the name is rebound for later statements. There is no opaque CPU-style += instruction inside the graph. The compact input is accepted for convenience; the durable text makes the read, operation, and rebind explicit.

Field compound assignment follows the same rule:

incident.attempts += 1

becomes:

incident.attempts = incident.attempts + 1

That form needs a typed numeric field. An open Struct field is Generic until the Flow proves more, so an author may have to read and convert it explicitly. any does not become safe merely because the uncertainty sits behind a dot.

A ternary is a value decision:

let status = urgent ? "critical" : "investigate"

It lowers to the Types Select node:

Boolean And result connected to Select.Condition, with the selected Result fanning out to Set Field and Format String.
The ternary becomes one Select data node. Its result can feed several consumers without creating execution branches.

This is not an execution branch. At runtime the Select node evaluates its condition and reads only the selected data input, but it exposes no alternative Execution arms. An impure operation placed inside an expression can also need its own place in the surrounding execution chain, so a ternary must not be used as a substitute for conditional side effects. Use a ternary for value-level selection. Use if or a named execution-arm block when only one side should call an external system, write data, or handle a failure. Chapter 10 develops that distinction through execution paths.

The condition must resolve to a Boolean input, and the two alternatives must be usable by the same selected output contract. With ordinary literals that is straightforward. With distinct schemas or container shapes, an explicit common boundary is safer than erasing the disagreement through Generic.

The renderer recognizes the current utils_types_select node specifically. That node presently has only condition, a, and b, so the ternary reproduces its full data contract. Unlike binary operator lowering, this path does not yet guard against future extra configured inputs. If the Select node grows another meaningful pin, the lossless-sugar test must grow with it.

9.4 Template literals are String Format nodes

Section titled “9.4 Template literals are String Format nodes”

Template literals turn a format operation into readable prose:

let summary = `${status}: ${normalized} after ${attempts} attempt(s)`

Apply converts that expression into one String Format node. Static text becomes its format_string input. Each interpolation becomes a dynamic input pin:

Format String node with dynamic status, normalized, and attempts input pins wired from Select, Trim String, and the event.
Template interpolation names survive as dynamic Studio pins, so every placeholder has an inspectable producer.

Simple references keep their names. A member or output access normally uses its final segment. More complex expressions receive stable positional names such as arg1. If two different expressions want the same placeholder name, the later one receives a suffix. Repeating the same bare reference reuses one placeholder pin, just as one graph output may fan out to several places in a format string.

This naming is not cosmetic. Dynamic pins are part of the node. The Board needs to know which wire supplies {status}, and text reconstructed from the Board must be able to produce the same set of pins again.

Template text therefore has one deliberate edge. Literal text shaped exactly like a format placeholder, such as {status}, cannot be treated as inert braces, because the underlying String Format node would interpret it as a pin. FlowScript rejects that ambiguous template. If a placeholder was intended, write the explicit format call and supply it; if literal braces were intended, change the text rather than relying on an escape the node itself cannot preserve.

The renderer returns to template syntax only when the round trip is exact. It requires:

  • a literal format string;
  • a value or wire for every placeholder;
  • no extra meaningful input pins; and
  • placeholder names that would be regenerated identically from the expressions.

If one of those checks fails, the Board renders the explicit call:

string::format({
formatString: "Incident {ticket}",
ticket: externalId,
})

That is the correct fallback. A slightly longer line is preferable to a pretty template that silently renames a dynamic pin or drops a configured value.

A dot can select two different graph concepts:

let found = split.found
let status = incident.status

The first expression may name an output pin on the node bound to split. The second reads a field from a Struct. Reconciliation tries the declared node output first. If no such output exists and the base is Struct-shaped, it lowers the expression to the appropriate Struct field-access node. A named interface lets that access retain the field’s declared type; an open Struct produces a more generic boundary.

There is a current read-side round-trip bug to keep separate from that intended rule. Generic Get Field exposes both value and found. Board-to-text lowering currently recognizes the node as a member access without first checking which output is wired. A wire from found can therefore render as incident.status, which ordinarily means the value. The safe authoring form when the presence flag matters is an explicit call and named output until lowering checks the selected pin.

const { value: status, found } = incident.get({ field: "status" })

A field write is equally concrete:

incident.status = "closed"

It lowers to Set Field with three data inputs—struct_in, the literal path "status", and the new value—and a struct_out output. FlowScript then rebinds incident so later uses resolve to that output.

It helps to see the successive values on the Board:

Two Set Field nodes connected in sequence, carrying successive Struct values while status and summary data feed their Value pins.
Each Set Field produces a new Struct wire for the next update; existing consumers remain connected to the earlier producer.

This is the precise sense in which the source appears to mutate a Struct. A consumer wired before the assignment keeps incident@0. A reference after the first write receives incident@1; after the second, it receives incident@2. The pins on each operation carry that operation’s current runtime value. The source name is the readable binding that selects which producer subsequent expressions use.

If the name is never used after a field write, the updated Struct is simply an unused copy. If it is used, it is the new version. Nothing travels backward through an existing wire to change a consumer that already received the earlier producer.

Nested literal paths follow the same model:

incident.owner.name = "Ada"
incident.affectedSystems[0].status = "degraded"

When the field itself is computed rather than a literal path, the compact dot assignment cannot name it honestly. The graph keeps an explicit Set Field call instead. The renderer also uses dot assignment only for an accumulator-shaped chain where the Set Field output becomes the next value of the same binding. A seed operation, a cross-source update, or a wired dynamic field remains an explicit call.

The chapter’s complete example applies all four kinds of readable sugar to the Incident Triage Flow:

use log::{ info }
eventsGeneric explainIncident(payload: Struct, incident: Struct, report: string, attempts: int, production: bool) {
const normalized = report.trim()
let urgent = production && (attempts >= 3)
let status = urgent ? "critical" : "investigate"
let previousStatus = incident.status
incident.status = status
incident.summary = `${status}: ${normalized} after ${attempts} attempt(s)`
info({ message: `${previousStatus} -> ${incident.status}: ${incident.summary}`, toast: false })
}

Read it once as code. It trims a report, classifies repeated production trouble, remembers the old status, produces two successive Incident values, and logs the transition. Then read the same logic as a graph:

Three Get Field nodes feeding previousStatus, status, and summary into the final Format String before Print Info.
The final focused region completes the graph detail shown in the three earlier crops: previous and updated Struct fields converge into the last format and log call.

The exact layout is Studio’s concern, but every meaningful operation in the source has a node or a pin value behind it. Conversely, every meaningful configured input on those nodes is intended to survive when the Board becomes text. The release gaps above identify where current lowering still falls short of that contract.

That second direction is where lossless sugar earns its name. Current lowering checks more than a node type:

Short formIt is rendered only when…Otherwise…
a op bthe node is a supported operator shape, its two operands are identifiable, and any omitted trailing inputs are untouched defaultskeep the catalog call
c ? a : bthe node is the recognized Types Select contract; today it has exactly the three represented inputskeep the catalog call
`…${x}…`the literal format and complete placeholder-pin set regenerate exactlykeep string::format(...)
record.field = valuea literal-path Set Field is rebinding the same Struct accumulatorkeep struct::set(...)

String equality makes the rule tangible. With the case option untouched, a graph can render:

let same = left == right

When the node’s ignoreCase input is enabled, the operator can no longer carry the contract, so the renderer preserves the setting:

const same = left.equal({ string: right, ignoreCase: true })

Applying or switching views may therefore make source longer. That is not a round-trip defect. It is the language refusing to lie about the graph.

Current Struct accumulator rendering makes that concrete in the chapter example. The first Set Field may read back as an explicit seed alias—possibly with a generated name such as record—and only the following same-accumulator update may return to record.summary = …. The parser fixture above is in canonical source form, but it is not a promise of byte-identical Board readback. The contract is the preserved graph and its configuration.

The complementary failure is equally useful. Change the example to:

let urgent = report + attempts

The parser can recognize the expression, but Apply cannot select one correct node family. It reports the String/Integer contradiction at that expression and does not manufacture a conversion. The repair is local and visible: choose formatting if this is a label, or choose a typed parse and handle its success if this is arithmetic.

These constraints are what make familiar syntax safe in a dual-view language. Operators are typed catalog operations. Ternaries select values rather than disguising execution branches. Templates retain their dynamic pins. Field assignment advances a visible Struct value instead of mutating the past. Whenever the compact notation cannot carry the entire decision, FlowScript shows the node call.

The next chapter adds visible execution structure: if, named outcome arms, sequential and parallel loops, while, and return. The same standard will apply there—short syntax is welcome only when every path still has an honest place on the Board.