Expression operations
Every operation the expression engine defines, organized by the type it operates on. This page is a verified snapshot of the engine’s catalog; the live source of truth is always:
cai expr ops --type <text|number|boolean|date|list|file|custom> --jsonWhen this page and the command disagree, the command wins. How expressions are authored — the write loop, binding roots, empty-value semantics — lives in Expressions.
Operations per type
| Type | Static ops | Plus |
|---|---|---|
| text | 22 | — |
| number | 19 | — |
| boolean | 8 | — |
| date | 12 | — |
| list | 18 | one dynamic each_<fieldId> per inner-type field |
| custom.X | 4 | one getter per schema field |
| dataRecord.custom.X | 4 | one getter per runtime schema field (adds id/createdAt/updatedAt) |
| file | 4 | 6 fixed field getters |
| output.X | 3 | data + error getters |
62 distinct operations exist across all types.
display strings are what the visual builder shows; the ids are what the stored AST contains.
| Operation | Parameters | Result type | Notes |
|---|---|---|---|
capitalized_words | none | text | Splits on a single space, uppercases the first character of each chunk, rejoins. Does not lowercase the rest, so "mcDONALD farms" → "McDONALD Farms". null → null. |
lowercase | none | text | null → null. |
uppercase | none | text | null → null. |
trimmed | none | text | null → null. |
slugify | none | text | NFKD-normalizes, strips combining marks, trims, lowercases, collapses every non-[a-z0-9] run to a single -, strips leading/trailing -. null → null. |
number_of_characters | none | number | value?.length ?? 0 — a null text yields 0, not null. |
format_json_encode | none | text | JSON.stringify(value). On text this adds surrounding quotes — it is an escape-for-embedding op, not a formatter. |
converted_to_number | none | number | Hardened: blank/non-numeric strings resolve to null, never NaN (the old Number(x) leaked NaN into arithmetic and produced output like "member for NaN days"). Numeric strings still coerce. Non-finite numbers → null. |
converted_to_date | properties: format_type, custom_format, timezone | date | Delegates to parseTextToDate. Never returns an Invalid Date and never throws. Default (no format_type, or auto) = strict ISO 8601. A supplied pattern is a hint: pattern first, strict ISO second, else null. epoch_seconds/epoch_millis require /^-?\d+$/ and a safe integer. timezone (default UTC) only affects offset-less input; an embedded offset/Z wins. |
converted_to_list | none | list.text | Always wraps: [value]. A null input becomes [null] — a 1-item list containing null, not an empty list. |
defaulting_to | args: scalar or TextExpression | text | “Empty” for text = null/undefined or a whitespace-only string, so " " IS replaced. is_slidable. |
equals | args: scalar or TextExpression | boolean | Compares String(a) === String(b) — both sides are String()-coerced, so a null value compares as the literal "null". |
not_equals | args | boolean | Same String() coercion. |
contains | args | boolean | String(a).includes(String(b)). |
not_contains | args | boolean | Negation of the above; display is doesn't contain. |
append | args | text | A null previous value is treated as '' and a null argument as '', so this never produces "null". |
split_by | args | list.text | null previous → []. A null/missing separator splits on "" (every character). |
find_replace | properties: find, replace (both TextExpression) | text | Literal, not regex — implemented as split(find).join(replace), so it replaces every occurrence. An empty/missing find returns the input unchanged. A missing replace deletes the matches. null previous → null. |
truncated_to | args: number literal or expression | text | slice(0, n) — keeps the first n characters (inclusive, unlike list_until). Number(arg) || 0, so a non-numeric or zero argument yields "". null previous → null. |
is_empty | none | boolean | value == null || value === ''. Whitespace-only text is not empty here (differs from defaulting_to). |
is_not_empty | none | boolean | Exact inverse of the above. |
summarize_with_ai | args: prompt TextExpression; properties: ai_model (default gpt-4o-mini) | text | Empty/null input short-circuits to null without an API call. Model ids starting with gemini throw. Runs on the system OpenAI connection and writes a third-party-LLM usage record; metering failures are logged, never fatal. |
Number
Section titled “Number”| Operation | Parameters | Result type | Notes |
|---|---|---|---|
floor | none | number | Guarded by toFiniteNumber — non-numeric input → null. |
ceiling | none | number | Same guard. |
absolute | none | number | Same guard. |
rounded_to | args: decimal places | number | Places are clamped to 0–15 and truncated to an integer. Uses a 15-significant-digit normalization plus an epsilon nudge so 1.005 rounds as a human expects. is_slidable. |
converted_to_text | none | text | null/undefined → '' (empty string, not null). |
converted_to_list | none | list.number | Wraps as [value]; null → [null]. |
defaulting_to | args | number | “Empty” for number = null/undefined or a non-finite number. 0 is NOT empty, so ?? 0 never overwrites a real 0. An empty string is also not platform-empty here and passes through unchanged. |
plus | args | number | All four arithmetic ops go through applyArithmetic: if either operand is missing/non-numeric the result is null, and a non-finite result (e.g. divide by zero) is also null. Numeric strings still coerce (integration payloads often carry numbers as text); blank strings do not. |
minus | args | number | As above. |
times | args | number | As above. |
divided_by | args | number | As above — division by zero yields null, not Infinity. |
greater_than | args | boolean | ⚠️ Raw JS > with no numeric guard, unlike the arithmetic ops. null/"" coerce to 0, so an empty value is < 5 (true) and not > 5. This is not fail-closed — contrast with the date comparisons. |
greater_than_or_equal_to | args | boolean | Same raw-comparison caveat. |
less_than | args | boolean | Same raw-comparison caveat. |
less_than_or_equal_to | args | boolean | Same raw-comparison caveat. |
equals | args | boolean | Strict ===, no String() coercion (unlike text equals). A numeric string "5" does not equal the number 5. |
not_equals | args | boolean | Strict !==. |
is_empty | none | boolean | value == null || value === ''. 0 is not empty. |
is_not_empty | none | boolean | Inverse. |
Boolean
Section titled “Boolean”| Operation | Parameters | Result type | Notes |
|---|---|---|---|
is_true | none | boolean | Strict value === true. A truthy non-boolean (e.g. 1, "yes") is not true. |
is_false | none | boolean | Strict value === false. Note both are false for null — is_true and is_false are not complements. |
format_boolean | properties: formatting_for_true, formatting_for_false (TextExpressions) | text | Ternary in text form. Anything that is not exactly true takes the false branch, including null. is_slidable. |
format_boolean_number | properties: formatting_for_true (default 1), formatting_for_false (default 0) | number | Same false-branch-catches-null behaviour. Each branch may be a literal number or a TextExpression. |
converted_to_text | none | text | null/undefined → ''; otherwise "true"/"false". |
format_json_encode | none | text | JSON.stringify(value). |
converted_to_list | none | list.boolean | Wraps as [value]. |
defaulting_to | args | boolean | “Empty” for boolean = null/undefined only. false is a real value and is never replaced. |
Boolean has no is_empty/is_not_empty in its own table — the JS layer still accepts .isEmpty() on any type and emits the op generically (see the JS section).
| Operation | Parameters | Result type | Notes |
|---|---|---|---|
formatted_as | properties: format_type (default MM/DD/YY), custom_format, timezone | text | format_type: 'iso_date' → .toISOString(); 'custom' → custom_format; anything else is used directly as a moment pattern, falling back to YYYY-MM-DD HH:mm:ss. Timezone defaults to UTC. Invalid input → null. |
converted_to_list | none | list.date | Wraps as [value]. |
plus_seconds | args: amount | date | All six plus_* ops share addToDate: if the date is unparseable or the amount is non-numeric the result is null. Negative amounts are supported (subtract). |
plus_minutes | args | date | As above. |
plus_hours | args | date | As above. |
plus_days | args | date | As above. |
plus_months | args | date | As above — moment calendar arithmetic, so month-end clamps (Jan 31 + 1 month = Feb 28/29). |
plus_years | args | date | As above. |
greater_than | args | boolean | ✅ Fail-closed: if either side fails to parse into a valid moment the result is false. Displayed as is after in the JS/op tables. |
less_than | args | boolean | Fail-closed the same way. Displayed as is before. Note there is no >=/<= and no equals on dates. |
is_empty | none | boolean | True for null, undefined, '', or a Date whose time is NaN. |
is_not_empty | none | boolean | Inverse. |
Date value tolerance (toValidMoment): accepts a real Date, an ISO 8601 string parsed strictly, or a finite epoch number. Anything else resolves to null rather than an Invalid Date — date-typed values routinely cross JSON transport as ISO strings.
list.<inner> is generated per inner type. path below = the inner type id.
| Operation | Parameters | Result type | Notes |
|---|---|---|---|
each_<fieldId> | properties: selectedProperty | list.<fieldType> | Dynamic — one op is generated per field of the inner type, only when the inner type is structured. Display is :each item's <Field>. List-valued fields auto-flatten one level (list.X stays list.X). Non-array input → []. Custom-typed fields stay typed so the chain keeps resolving. |
count | none | number | value?.length ?? 0. |
sum | none | number | ⚠️ Only registered when the inner type is exactly number. Non-array → 0. Uses raw + with no coercion, so a list of numeric strings would concatenate. |
average | none | number | Same number-only restriction. Empty or non-array list → null (not 0). |
first_item | none | inner type | value?.[0] ?? null. |
last_item | none | inner type | value?.[length - 1] ?? null. |
item_number | args: index | inner type | 1-based: reads value[n - 1]. Display :item #. |
list_from | args: index | list.<inner> | slice(n - 1) — 1-based and inclusive of item n. |
list_until | args: index | list.<inner> | ⚠️ slice(0, n - 1) — EXCLUSIVE. :items until 3 returns the first two items. Asymmetric with list_from, and asymmetric with text truncated_to (which is inclusive). |
filtered | properties: constraints, sort_field, descending, ignore_empty_constraints | list.<inner> | Two passes, both applied: simple field constraints narrow the list first, then advanced constraints (constraint_type: 'empty', i.e. a boolean expression evaluated per item with the item injected) run on the narrowed result. A constraint whose field no longer exists on the schema is logged and skipped. With ignore_empty_constraints: true a constraint whose value evaluates to nil is skipped instead of matching nothing (opt-in — the default is fail-closed). Optional sort_field sorts the result. |
list_contains | args | boolean | Non-array → false. Primitives use includes; objects are compared by JSON.stringify equality. |
sorted | properties: sort_field, descending (default false) | list.<inner> | Non-array → []. Sorts a copy. Nil values always sort last regardless of direction. Comparison is type-aware via the field’s returnCType (date → epoch, number → numeric, boolean → 0/1), else localeCompare on the stringified values. |
format_as_text | properties: content (TextExpression, item injected), delimiter (TextExpression) | text | Empty list → ''. Default separator is '' (no delimiter), unlike join_with. |
join_with | args: separator | text | Non-array → ''. Default separator is ', ' when the argument is nil. Every item is String(item)-coerced, so objects become [object Object]. |
stringify | none | text | JSON.stringify, falling back to String(value) if that throws. null/undefined → ''. |
is_empty | none | boolean | ⚠️ value?.length === 0 — for a null/undefined list this evaluates to undefined (falsy), not true. |
is_not_empty | none | boolean | value?.length > 0 — also undefined for a nil list. |
merge_with | args: another list | list.<inner> | Concatenates. A nil previous value is treated as []; a missing argument returns the previous list unchanged. No de-duplication, no type check on the second list. |
find_with_ai | args: criteria TextExpression; properties: model (default gpt-4o-mini) | inner type | Empty list short-circuits to null without an API call. gemini* model ids throw. The whole list is JSON-serialized into the prompt; the model returns a 0-based index, and an out-of-range or -1 answer yields null. Writes a usage record. |
Filter constraint types (ConstraintType, used by filtered): empty (advanced/boolean-expression), equals, not_equals, contains, not_contains, is_empty, is_not_empty, is_in, is_not_in, greater_than, less_than, greater_than_or_equal_to, less_than_or_equal_to, contains_item, not_contains_item.
Constraint gotchas from applyConstraints: item values are read as item[field.id] ?? item[field.display] (ids and displays never collide, so either keying works); ordered comparisons use raw JS operators with no coercion guard; is_in/is_not_in require the constraint value to actually be an array — is_not_in against a non-array keeps every item.
Custom (custom.X) and data records (dataRecord.custom.X)
Section titled “Custom (custom.X) and data records (dataRecord.custom.X)”Both providers expose the same four static ops plus one getter per schema field.
| Operation | Parameters | Result type | Notes |
|---|---|---|---|
<fieldId> | properties: selectedProperty | the field’s declared type | Dynamic — one op per field, category dataFields. Values are read as item[primary] ?? item[secondary] ?? null, where the primary key is the field id for integration/output/flow types and the field display for regular workflow-data types. On dataRecord, custom- and record-typed fields are resolved through the workflow-data store rather than read inline. |
stringify | none | text | JSON.stringify with a String(value) fallback; null/undefined → ''. |
converted_to_list | none | list.custom.X / list.dataRecord.custom.X | Wraps as [value]. |
is_empty | none | boolean | lodash isNil — only null/undefined count. An empty object {} is not empty. |
is_not_empty | none | boolean | Inverse. |
Differences between the two shapes:
custom.Xis the pure user schema. Its fields come fromgetWorkflowDataUserSchemaFields(or the rawdataType.fieldsfor integration typescustom.__*).dataRecord.custom.Xis the persisted-record presentation: the same schema flattened together with the record fields (getWorkflowDataRuntimeSchemaFieldsadds id/createdAt/updatedAt). Its display is"<Type> record".- Integration custom types (
custom.__*) cannot be data records — the provider throws.
file is a fixed built-in type: a Controller-hosted file with metadata.
| Operation | Parameters | Result type | Notes |
|---|---|---|---|
id / fileName / fileType / mimeType / url | properties: selectedProperty | text | Plain property reads, ?? null. |
sizeBytes | properties: selectedProperty | number | Plain property read. |
stringify | none | text | As above. |
converted_to_list | none | list.file | Wraps as [value]. |
is_empty | none | boolean | isNil. |
is_not_empty | none | boolean | Inverse. |
Output wrapper (output.X)
Section titled “Output wrapper (output.X)”Node results are wrapped as { data: <innerType>, error: text }.
| Operation | Parameters | Result type | Notes |
|---|---|---|---|
data | properties: selectedProperty | the inner type | value?.data ?? null. |
error | properties: selectedProperty | text | value?.error ?? null. |
converted_to_list | none | list.output.X | Wraps as [value]. |
is_empty / is_not_empty | none | boolean | isNil-based. |
Wrappers are transparent to the op resolver: the compiler’s unwrapForOps peels node.<id>#data, dataRecord., and output. prefixes (up to 6 levels) before looking up which ops apply, so ops on the inner type work straight through. The one exception is ??, which is rejected directly on an output. wrapper — you must take .data first.
JS expression syntax
Section titled “JS expression syntax”Users and agents write a restricted JavaScript-like syntax that is parsed with acorn and compiled to the op AST above. The grammar:
Optional
const x = ...;declarations plus ONE final expression. Values: string/number/boolean literals, template literals, and chains starting fromflow.<name>/inputs.<name>/process/item/now/workflowData("custom.x"), extended with per-type methods, field access, comparisons (chain on the LEFT), ternary, and!on booleans. Arrow functions only inside list.filter()/.some()/.find()/.every(), list.map(x => x.field), or list.formatAsText(x => ...).
The CLI entry points are cai expr context, cai expr validate, cai expr set, cai expr ops, and cai expr decompile.
Binding roots
Section titled “Binding roots”These are the only identifiers that start a chain. Names verified against compileNode’s Identifier case and the DataSource enum.
| JS root | Compiles to | Type | Notes |
|---|---|---|---|
flow.<name> | data_source / flow_input (flowInputId) | the input’s declared type | Namespace, dot access only. Resolution tries exact name, then camelCase→snake_case, then display-name match, then a case/separator-insensitive squash. In strictBindings mode the last two fallbacks are errors with a suggested rewrite. |
inputs.<name> | data_source / node_input (inputId) | the upstream node’s output type | Same four-level resolution. This is also how trigger nodes are referenced — there is no trigger root; the compiler only tracks nodeLocations[nodeId] === 'trigger' to tailor error hints. Binding names are camelCased from the display name, de-duplicated with a numeric suffix. |
process | data_source / process_output | processOutputType, else output.unknown | The current process’s result. Display Process result. |
item | injected_value | the injected source’s type | The loop / per-item value. Errors with “item (injected value) is not available here” when the context has no injection source. There is no this-item / thisItem spelling. |
now (alias currentDate) | data_source / date | date | Current date/time. The DataSource enum member is named dateTime but its wire value is date. |
isLiveVersion (alias live) | data_source / is_live_version | boolean | Display Is live version. |
workflowData("custom.x") | data_source / workflow_data_search | list.custom.x | A function call, not a namespace. Requires a string literal type argument; stores it as properties.workflowDataType. Always returns a list. |
true / false literals | data_source / boolean_true | boolean_false | boolean | Boolean literals compile to fixed data sources, not to inline values. |
const bindings declared earlier in the program are also valid chain starts; they are inlined (structurally cloned) at each use.
Template literals and text building
Section titled “Template literals and text building”- A template literal compiles to a
partsvalue: literal chunks stay as strings, each${...}becomes an embedded expression block, and the whole thing is typedtext. +is overloaded: if either side is textual it produces the samepartsconcatenation; if both sides are number literals it constant-folds; otherwise it looks up the type’s binary op (plus, etc.).- Comparisons want the chain on the left. A literal-on-the-left comparison (
5 < flow.x) is auto-flipped to the mirrored chain-left spelling (>↔<,>=↔<=,===/!==unchanged). String()is rejected as unnecessary;JSON.stringify/JSON.parseare rejected with a pointer to.stringify()/.jsonEncode().
JS construct → op
Section titled “JS construct → op”| JS construct | Op id | On type | Arg form |
|---|---|---|---|
.toLowerCase() | lowercase | text | none |
.toUpperCase() | uppercase | text | none |
.capitalizedWords() / .capitalize() | capitalized_words | text | none |
.trim() | trimmed | text | none |
.slugify() | slugify | text | none |
.length (property) | number_of_characters | text | none |
.toNumber() / Number(<text chain>) | converted_to_number | text | none |
.toDate([format][, tz]) | converted_to_date | text | string literals; "iso_date"/"auto" store no format_type; "epoch_seconds"/"epoch_millis" are special and reject a tz argument; presets pass through; anything else becomes custom + custom_format |
.toList() | converted_to_list | text/number/boolean/date | none |
.jsonEncode() | format_json_encode | text, boolean | none |
x ?? <literal> | defaulting_to | text, number, boolean | Fallback must be a literal of the matching type. Rejected on date, list, custom, dataRecord.*, and output.* |
=== | equals | text, number | scalar |
!== | not_equals | text, number | scalar |
.includes(v) | contains (text) / list_contains (list) | text, list | scalar |
.notIncludes(v) | not_contains | text | scalar |
.isEmpty() | is_empty | any | none — emitted generically even for types with no table entry |
.isNotEmpty() | is_not_empty | any | none — same |
.summarizeWithAI([prompt][, model]) | summarize_with_ai | text | prompt defaults to “Summarize the given text concisely, capturing the key points.”; model key is ai_model |
.split(sep) | split_by | text | scalar |
.concat(v) / .append(v) | append (text) / merge_with (list) | text, list | scalar (text) / chain (list) |
.truncatedTo(n) / .slice(0, n) | truncated_to | text | number literal or expression; only the slice(0, n) shape is accepted |
.replace(find, repl) / .replaceAll(...) | find_replace | text | two args → properties.find / properties.replace |
.floor() / Math.floor(x) | floor | number | none |
.ceiling() / Math.ceil(x) | ceiling | number | none |
Math.abs(x) | absolute | number | none (global form only) |
.roundedTo(n) | rounded_to | number | number literal, at most one; defaults to 0 |
.toText() | converted_to_text | number, boolean | none |
+ - * / | plus, minus, times, divided_by | number | scalar |
> >= < <= | greater_than, greater_than_or_equal_to, less_than, less_than_or_equal_to | number | scalar |
> / < on a date chain | greater_than / less_than | date | scalar (is after / is before) |
.isTrue() / .isFalse() | is_true / is_false | boolean | none |
!<boolean chain> | is_false | boolean | none |
cond ? a : b | format_boolean_number when both branches are number literals, else format_boolean | boolean | branches become formatting_for_true / formatting_for_false |
.format(fmt[, tz]) | formatted_as | date | string literal; a known preset sets format_type, otherwise format_type: 'custom' + custom_format |
.plusSeconds(n) … .plusYears(n) | plus_seconds … plus_years | date | number literal only; negative literals are supported (now.plusDays(-1)) |
.length (property) on a list | count | list | none |
.sum() / .average() | sum / average | list | none — only meaningful on list.number |
.first() / .last() | first_item / last_item | list | none |
.item(n) | item_number | list | number literal, 1-based |
list[0] | first_item | list | index must be a number literal |
list[n] (n > 0) | item_number with n + 1 | list | compiles with a warning — JS is 0-based, the engine is 1-based |
.itemsFrom(n) / .itemsUntil(n) | list_from / list_until | list | number literal, 1-based |
.filter(pred[, opts]) | filtered | list | arrow predicate → constraints; optional { ignoreEmptyConstraints: true } |
.some(pred) | filtered + is_not_empty | list | desugar |
.find(pred) | filtered + first_item | list | desugar |
.every(pred) | single condition → negated filtered + is_empty; multi-condition AND (or an ordered OR) → filtered + count compared with the unfiltered count | list | desugar |
.map(x => x.field) | each_<fieldId> | list | single field pick only (x.field or x["exact_id"]) |
.formatAsText(x => \…`[, sep])` | format_as_text | list | arrow body → properties.content with the item injected; second arg → properties.delimiter |
.join(sep) | join_with | list | scalar |
.sortBy(field[, "asc"|"desc"]) / .sorted(...) | sorted | list | string literals; the field is validated against the inner schema |
.findWithAI(criteria[, model]) | find_with_ai | list | model key is model (a known engine inconsistency vs ai_model) |
.stringify() | stringify | custom, list, file | none |
.<fieldName> | the field getter op | custom / schema-backed | camelCase of the field id; use ["raw_id"] for ids that do not camelCase cleanly |
.data / .error | data / error | output wrapper | field access delegates through the wrapper; an exact schema field id named data wins over the built-in accessor, but a display-name match does not shadow it |
Predicate grammar (inside .filter() / .some() / .find() / .every())
Section titled “Predicate grammar (inside .filter() / .some() / .find() / .every())”Single-parameter arrow with an expression body (no { } block). Each condition starts with <param>.<field> or <param>["exact_field_id"] followed by ===, !==, >, <, >=, <=, .includes(v), !....includes(v), .isEmpty(), or .isNotEmpty(). Methods and arithmetic on the field side are rejected — compute on the value side instead.
Constraints from the code that shape what compiles:
- Multi-condition AND groups join only with
&&.||joins single conditions only and cannot mix with&&. - At most 4
||branches. - At most one ordered comparison (
>,<,>=,<=) per||group — for a null/NaN/non-numeric field the engine makes both the comparison and its negated guard false, which would silently drop a later matching branch. ||cannot be combined with{ ignoreEmptyConstraints: true }(an empty-skipped guard would break branch disjointness and duplicate items).||returns branch-major order rather than source order — warned for.filter(), and warned separately for.find()where branch order decides which match wins.||overworkflowData()runs the search once per branch;.every()overworkflowData()runs it once per branch plus once more for the unfiltered count. Each run is an independent snapshot.
Not supported
Section titled “Not supported”new / Date objects, array literals, object literals, optional chaining ?., &&/|| outside list predicates, loops, assignments, destructuring, await, spread, tagged templates, IIFEs, computed calls obj[m](), user-defined functions, regex (.match()), .startsWith()/.endsWith(), .toFixed(), .forEach()/.reduce(), parseInt/parseFloat, JSON.stringify/JSON.parse, and dynamic list indexes (indexes must be number literals — this is an engine limitation, not just a DSL one). Non-const declarations are rejected, and the last statement must be the result expression.
Type coercion rules
Section titled “Type coercion rules”The one implicit widening
Section titled “The one implicit widening”The complete assignability rule set:
- Identical ids are assignable.
- List-ness must match on both sides —
list.Xis never assignable toXor vice versa. - Identical base types (after stripping the
list.prefix) are assignable. - The only widening: a
dataRecord.source is assignable to a non-dataRecordexpected type when the inner types match. SodataRecord.custom.lead→custom.leadis allowed, andlist.dataRecord.custom.lead→list.custom.leadis allowed.
The reverse is explicitly not allowed: custom.lead is not assignable to dataRecord.custom.lead. A plain custom shape has no persisted id/createdAt/updatedAt, so it cannot stand in for a record.
There are no other implicit conversions. Text→number, number→text, text→date and so on all require an explicit op (converted_to_number, converted_to_text, converted_to_date).
What text interpolation does per value type
Section titled “What text interpolation does per value type”In text templates, each entry is a list of items; items are concatenated with '', and entries are joined with '\n' (that is how multi-line templates are represented).
| Item / evaluated value | Result |
|---|---|
| A literal string item | Passed through unchanged. |
A null item (not value) in the entries array | Throws Null expression found. |
Evaluated value null or undefined | Renders as '' — an empty binding silently vanishes rather than printing "null". |
A plain object (non-array, non-Date, non-null) | Throws Object value found. This is a deliberate tripwire, not a bug: the comment says it exists “to catch issues in time”, and each case that turns out to be legitimate gets added to the skip conditions. Interpolate a field or .stringify() instead. |
| An array | Allowed — falls through to .toString(), i.e. comma-joined with no spaces. |
A Date | Allowed — falls through to .toString(), i.e. the JS date string, not ISO. Use .format() for a controlled rendering. |
| A number / boolean | .toString() — 5 → "5", true → "true". |
The result of the whole interpolation is always typed text.
Scalar / expression-mode coercion
Section titled “Scalar / expression-mode coercion”In processExpression / outputExpression / processCondition mode the engine does not interpolate:
- A raw primitive prop value that is not a TextExpression is coerced up into
{ type: 'TextExpression', entries: [[String(value)]] }, with a warning logged. - A scalar TextExpression literal is coerced by JS type and typed accordingly:
number→number,boolean→boolean, everything else →text. - A non-TextExpression in these modes throws a shape error naming the expression path and the expected shape.
Compile-time type checking (JS layer)
Section titled “Compile-time type checking (JS layer)”When the compiled expression is written back to a prop, acceptedType decides the strictness:
text— anything is wrapped as a TextExpression.onlyWhen— the expression must infer toboolean, otherwise it is an error.- Any other concrete type — a literal or template whose inferred type differs is an error; a chain whose inferred type differs is a warning (the engine’s own coercion may still be correct at runtime).
unknown— always a warning when the inferred type is anything other thanunknown.