Triggo Documentation
Workflow Builder

Conditions and branching

Full operator reference and branching semantics for the Condition node — every operator, truthy/falsy rules, and how skipped branches propagate.

Conditions and branching

The Condition node is a two-way router — it evaluates a group of conditions against upstream data and sends execution down either the true branch or the false branch. system-nodes.mdx describes the inputs, outputs, and config shape at a glance; this page is the deep-dive reference for the full operator catalogue, the coercion rules the executor applies, how skipped branches propagate, and how to express compound logic when a single node isn't enough.

Branching semantics

The engine's Condition handler evaluates the node's ConditionConfig against the resolved upstream context and writes a step_completed event with outputData: { branch: "true" | "false" }. It then walks every outgoing edge from the node — each edge carries a sourceHandle of either "true" or "false" — and does two things for the branch that was not taken:

  1. Adds every non-taken edge's id to the executor's skippedEdgeIds set, so the DAG resolver treats those edges as satisfied rather than pending. This lets cascadeSkip keep moving downstream past the Condition node.
  2. Calls getBranchNodes(definition, node.id, skippedBranch) to enumerate every descendant reachable only through the pruned branch, and appends a step_skipped journal event for each one. Those nodes are added to completedNodeIds without ever being executed.

The executor's output for the Condition node itself is { branch: "true" | "false", matched: <per-condition boolean[]> }. matched is the individual result of each sub-condition before the AND/OR combinator — useful for debugging which row in a multi-condition group fired.

Downstream Merge inputs

When a branch is skipped and a Merge node sits further downstream, the Merge does not wait forever for the pruned input. The Merge handler treats an edge whose branch was skipped as contributing null for that targetHandle, so the join completes as soon as the live branches arrive. See and the Merge section of system-nodes.mdx.

Operators reference

Every row below is an operator declared in. Examples use the field value on the left of the comparison (resolved from upstream context by the field name in your condition row) and the configured value on the right. There are 23 operators total.

Text (8)

All text operators require both operands to be JavaScript strings. If either side is missing or non-string, the operator returns false with no coercion — numbers are not stringified, null/undefined do not match empty string. All text comparisons are case-sensitive.

OperatorOperand typesSemanticsExample
TEXT_CONTAINSstring, stringfirst.includes(second)"acme corp" contains "corp" → true
TEXT_DOES_NOT_CONTAINstring, string!first.includes(second)"acme corp" does not contain "gmbh" → true
TEXT_EXACTLY_MATCHESstring, stringfirst === second"paid" exactly matches "paid" → true
TEXT_DOES_NOT_EXACTLY_MATCHstring, stringfirst !== second"paid" does not exactly match "PAID" → true
TEXT_STARTS_WITHstring, stringfirst.startsWith(second)"+79851234567" starts with "+7" → true
TEXT_DOES_NOT_START_WITHstring, string!first.startsWith(second)"user@acme.com" does not start with "admin@" → true
TEXT_ENDS_WITHstring, stringfirst.endsWith(second)"order.pdf" ends with ".pdf" → true
TEXT_DOES_NOT_END_WITHstring, string!first.endsWith(second)"invoice.pdf" does not end with ".docx" → true

Number (6)

Number operators coerce both sides through toNumber(): a real JavaScript number passes through (except NaN, which becomes null), a numeric string is parsed via Number(value), and anything else becomes null. If either side coerces to null, the operator returns false. This means "42" compares equal to 42, but true, null, undefined, arrays, and objects all fail the comparison.

OperatorOperand typesSemanticsExample
NUMBER_EQUAL_TOnumber|numeric-stringa === b after coercion"100" equal to 100 → true
NUMBER_NOT_EQUAL_TOnumber|numeric-stringa !== b after coercion99 not equal to 100 → true
NUMBER_GREATER_THANnumber|numeric-stringa > b after coercion150 greater than "100" → true
NUMBER_LESS_THANnumber|numeric-stringa < b after coercion50 less than 100 → true
NUMBER_GREATER_THAN_OR_EQUAL_TOnumber|numeric-stringa >= b after coercion100 greater than or equal to 100 → true
NUMBER_LESS_THAN_OR_EQUAL_TOnumber|numeric-stringa <= b after coercion100 less than or equal to 100 → true

Boolean (2)

Boolean operators coerce only the left operand and ignore the right. The coercion is deliberately narrow: the literal boolean true and the string "true" both read as true; everything else — including false, "false", "True", 1, 0, null, undefined, arrays, and objects — reads as false. If you need to branch on "yes", "1", or a JSON true inside a string, use TEXT_EXACTLY_MATCHES instead.

OperatorOperand typesSemanticsExample
BOOLEAN_IS_TRUEanytoBooleanValue(first) === truetrue or "true" → true; 1 → false
BOOLEAN_IS_FALSEanytoBooleanValue(first) === falseany value that isn't true / "true" → true

Existence (2)

Existence operators check whether the resolved field path produced a value at all. They use strict null/undefined checks, so an empty string "", the number 0, false, and empty arrays all count as existing.

OperatorOperand typesSemanticsExample
EXISTSanyfirst !== null && first !== undefined"" exists → true; missing field → false
DOES_NOT_EXISTanyfirst === null || first === undefinedmissing company field → true

List (4)

List operators require the left operand to be a JavaScript array. If it isn't — including when the field is missing or is an object — they return false. LIST_CONTAINS / LIST_DOES_NOT_CONTAIN use Array.prototype.includes, which is strict-equality based and does not reach into object members; use FILTER or LOOP for deeper matching.

OperatorOperand typesSemanticsExample
LIST_CONTAINSarray, anyfirst.includes(second) (strict equality)["paid", "trial"] contains "paid" → true
LIST_DOES_NOT_CONTAINarray, any!first.includes(second)["paid", "trial"] does not contain "free" → true
LIST_IS_EMPTYarrayfirst.length === 0[] is empty → true
LIST_IS_NOT_EMPTYarrayfirst.length > 0["a"] is not empty → true

Date (1)

The date operator coerces each side through toDate(): strings and numbers are passed to new Date(), anything else becomes null. Invalid dates (new Date(...) returns NaN time) also become null. If either side is null, the operator returns false. Comparisons are in milliseconds since epoch, so an ISO string and a Unix timestamp compare correctly against each other.

OperatorOperand typesSemanticsExample
DATE_IS_BEFOREstring|number (parseable as Date), samea.getTime() < b.getTime()"2026-04-01" is before "2026-05-01" → true

Truthy, falsy, and coercion rules

A short summary of the rules derived from condition-operators:

  • Strings vs. numbers. Only the number operators coerce. Every text operator type-checks both sides as string and returns false otherwise. Never rely on TEXT_EXACTLY_MATCHES against a number — stringify it upstream with a Set node if needed.
  • null and undefined. All operators except EXISTS, DOES_NOT_EXIST, and the boolean operators return false when either relevant operand is null or undefined. EXISTS returns true for empty strings, 0, false, and [] — they are all defined values.
  • Booleans. The boolean operators accept only literal true or the exact string "true" as truthy. This is intentional, to keep webhook payloads that vary between true, "true", "yes", and 1 predictable.
  • Arrays. List operators require a real array; an object with numeric keys is not an array. LIST_CONTAINS uses strict equality, so [1, 2, 3] does not contain "1".
  • Dates. Any value that new Date() parses to a non-NaN time is valid. ISO 8601 strings and Unix epoch milliseconds both work; arbitrary formatted strings (for example, "4/1/2026") are parser-dependent and best avoided.
  • Case sensitivity. Text operators are always case-sensitive. The ConditionConfig schema accepts an optional caseSensitive flag for forward compatibility, but the operator evaluator does not consult it today. Lowercase both sides with a Set node if you need case-insensitive matching.

Combining conditions

A single Condition node already supports compound logic natively. ConditionConfig carries an array of condition rows plus a combinator of "AND" or "OR":

  • combinator: "AND" → the branch is taken only when every row evaluates true (per.every(Boolean)).
  • combinator: "OR" → the branch is taken when any row evaluates true (per.some(Boolean)).

This covers most two-way branches. When you need more than two destinations, or a mix of AND and OR that one combinator can't express, the product supports two patterns:

  • Chained Condition nodes for AND-of-OR. Place one Condition after another on the true branch. Each node narrows the population further; any false along the chain prunes the rest via the normal skipped-edge mechanism.
  • Switch for N-way routing. When you have three or more mutually exclusive destinations — country, plan tier, event name — reach for the Switch node instead. Switch evaluates its branches in order and takes the first match, with an optional fallback handle. See the Switch section in system-nodes.mdx.
  • Parallel Condition nodes for OR-of-AND. If two independent compound predicates each need to trigger the same action, fan out the upstream node to two Condition nodes and join their true branches at a Merge before the shared downstream.

Nested boolean logic beyond these patterns is a sign the branch should move into a Code node that returns a simple flag, which a single Condition then routes on.

Example — webhook lead enrichment

A realistic toy scenario that uses the existence operators end to end:

Webhook · lead_captured                   -- body: { email, name }
  → HTTP Request · GET /enrichment/:email  -- output: { company?: string }
  → Condition (combinator: AND,
               conditions: [
                 { field: "company", operator: EXISTS }
               ])

       ├── true  → HubSpot · Upsert contact (with company)
       │        → Slack · "New enriched lead: {{trigger.name}} at {{company}}"

       └── false → HubSpot · Upsert contact (basic)
                 → Slack · "New lead (no enrichment): {{trigger.name}}"

The EXISTS row is the key cell in the Existence table — it returns true for any value that is not null or undefined, which is exactly the semantic "the enrichment call returned a company name, even an empty string". If you want to treat "" as missing too, swap EXISTS for TEXT_DOES_NOT_EXACTLY_MATCH with value "", combined with EXISTS under combinator: "AND".

  • System nodes — inputs, outputs, and one-line semantics for Condition, Switch, Merge, Loop, and the rest of the flow family.
  • Field mapping — how field paths like trigger.body.email and {{nodeId.company}} resolve against upstream outputs.
  • Error handling — what happens on the live branch if a downstream action fails.

On this page