Definitions in depth
Expressions
Condition expressions let you write conditions as text, such as country == 'nl' and plan == 'paid', instead of a YAML tree. The CLI turns each expression into its tree as it reads your project, so lint, builds, datafiles and SDKs see exactly what they always did. This page shows a rule both ways, turns expressions on, then writes each kind of condition from Conditions as an expression.
Read a rule at a glance#
A tree names every part of a condition, so it is hard to misread. But a rule with three conditions takes a dozen lines, and a pull request repeats attribute, operator and value for each. checkout's rule for Dutch accounts that pay, or that have 50 seats or more, as a tree:
# ...rules: - key: netherlands conditions: - netherlands - or: - payingAccounts - attribute: account.seats operator: greaterThanOrEquals value: 50 percentage: 100 - key: everyone conditions: "*" percentage: 25The same rule, written as an expression:
# ...rules: - key: netherlands conditions: | netherlands and ( payingAccounts or account.seats >= 50 ) percentage: 100 - key: everyone conditions: "*" percentage: 25Both build the same tree, so checkout reaches the same users either way. Expressions are opt in, and trees keep working beside them, so a project can write one rule this way or all of them.
Turn them on#
Set conditionExpressions in your project's configuration:
module.exports = { conditionExpressions: true,}Without it, lint reports each expression at its file, line and column:
=> Error: Condition expressions are not allowed in this project. segments/netherlands.yml:3:13 conditions: country == 'nl' ^^^^^^^^^^^^^^^Its hint names the option, and says to write the condition as a YAML tree otherwise. Every other command reads expressions either way, so turn it on before anyone writes one, and keep it on while any file has one. convert turns it on and writes your project's trees as expressions in one step.
Move a project to expressions#
convert writes every condition tree in a project as an expression, then each condition in its simplest form, and turns conditionExpressions on. Without --apply it previews, and it writes nothing until a converted copy of the project lints, tests and evaluates the same:
npx featurevisor convert --to=expressions --applyThe payingAccounts segment, written as a tree:
description: Paid and enterprise accountsconditions: - attribute: plan operator: in value: - paid - enterprisereads like this afterwards:
description: Paid and enterprise accountsconditions: plan in ['paid', 'enterprise']The tree was a list of one condition, and the expression is that condition on its own. The datafile stores it in that simpler shape, and every SDK evaluates both the same. Comments inside a tree move above the condition they sat beside.
A few trees stay as they are: a disabled variation leaf, which has no expression form, and any tree whose conditions: line has # featurevisor-ignore-line. The preview names the files with a leaf that has no expression form.
Pass --exact to keep every datafile byte identical instead, leaving a list of one condition as a list of one expression. Since every condition evaluates the same, diff reports no changed definitions afterwards. It lists the files whose conditions are written another way instead. To tidy conditions written later, run fmt --simplify, which writes each in its simplest form the same way.
Go back to trees#
--to=trees writes every expression as the tree it builds, with each comment line above the condition below it, and removes conditionExpressions:
npx featurevisor convert --to=trees --applyThe same checks run first, so a project can try expressions and leave them again with every evaluation the same throughout. Each expression comes back as the tree it builds, so payingAccounts returns as a single condition rather than a list of one.
Write a condition#
The netherlands segment compares the country attribute, here as a tree:
description: The Netherlandsconditions: attribute: country operator: equals value: nlThe same condition as an expression:
description: The Netherlandsconditions: country == 'nl'Both match the same users. Strings are in single quotes, so YAML can hold an expression without quoting it.
Compare an attribute#
A name followed by an operator is an attribute. A dotted path reads a property of an object attribute, as account.seats does:
description: Accounts with 50+ seatsconditions: account.seats >= 50The symbols ==, !=, >, >=, < and <= compare. Every other operator keeps its own name, between the attribute and the value:
description: Paid and enterprise accountsconditions: plan in ['paid', 'enterprise']Operators in expressions lists all 32.
Reference a segment#
A name on its own is a segment, as a bare string in a YAML list is. The netherlands rule can narrow checkout to Dutch paying accounts:
description: Checkout redesignbucketBy: userIdrules: - key: netherlands conditions: | netherlands and payingAccounts percentage: 100 - key: everyone conditions: "*" percentage: 25An attribute and a segment can share a name, because position tells them apart: plan == 'paid' compares the attribute, and plan alone names a segment.
Require another feature#
feature('liveChat') matches while liveChat is enabled. File upload only makes sense inside live chat:
description: File upload inside chatbucketBy: userIdconditions: feature('liveChat')rules: - key: everyone conditions: "*" percentage: 100Write feature('liveChat') == false to require the feature to be off. A feature key is always quoted inside feature(...), so a name never reads as a feature by accident.
Require a variation#
.variation after feature(...) compares the result of getVariation. Both the enabled state and the variation must match:
# ...conditions: feature('pricing').variation == 'control'Require a variable value#
variable(...) reads a global variable's value. A variable always needs a comparison, so checkout stays off while maintenance mode is on:
# ...conditions: variable('maintenanceMode') == falseA missing value keeps checkout off too, because == false does not match it. variable('maintenanceMode') alone is a lint error that suggests a comparison.
A dot after the variable reads part of a structured value, as path does in a tree:
# ...conditions: variable('checkoutSettings').currency == 'EUR'Any other name after feature(...) reads that feature's variable. It does not require the feature to be enabled, so disabled fallbacks still take part:
# ...conditions: feature('buyButton').label == 'Buy now'Array operators compare a variable's array with candidates:
# ...conditions: variable('checkoutSettings').paymentMethods includesAny ['card', 'ideal']A condition should not read a json variable, or inside an object declared without properties or additionalProperties, because their contents have no declared types. Lint warns, and names the variable to give a declared shape.
Combine conditions#
and, or and not combine conditions, with parentheses for groups:
| Expression | Matches when |
|---|---|
a and b | Both match |
a or b | Either matches |
not a | a does not match |
not (a and b) | a and b do not both match |
a and (b or c) | a matches, and b or c does |
a to c stand for any condition. not binds tightest, then and, then or. Lint warns when and and or mix without parentheses, and its hint shows where they would go. The words are lowercase only, as the tree's keys are, and "*" still matches everyone as the whole value.
File upload needs live chat and stays out of pricing's treatment:
description: File upload inside chatbucketBy: userIdconditions: | feature('liveChat') and not feature('pricing').variation == 'treatment'rules: - key: everyone conditions: "*" percentage: 100Write one condition a line#
The | keeps the expression's lines as they are written. fmt writes every expression with more than one condition this way: one condition a line, each after the first starting with its and or or, and a group's conditions between its parentheses, indented a level. A line starting with # is a comment:
description: Checkout redesignbucketBy: userIdrules: - key: netherlands conditions: | netherlands and ( # Paying accounts, or any account with 50+ seats payingAccounts or account.seats >= 50 ) percentage: 100 - key: everyone conditions: "*" percentage: 25Mix expressions and trees#
Any string in conditions can be an expression: the whole value, an item in a list, or a child of and, or and not. A list still means AND, so each item can be a segment, an expression or a tree leaf:
# ...conditions: - netherlands - plan == 'paid' or account.seats >= 50 - variable('maintenanceMode') == falseconvert --to=expressions would join this list into one expression, since that builds the same tree. A string is read by its shape: "*" matches everyone, a string shaped like a segment key, such as netherlands or countries.netherlands, is that segment, and any other string is an expression. Adding a segment never changes how another file reads.
Where expressions go#
Everywhere a condition goes: segments, a feature's root conditions, rules, force entries, variable overrides in rules and variations, and a global variable's root conditions and overrides. Where conditions go lists them. Test specs keep their own checks.
Values#
| Value | Written as |
|---|---|
| Text | 'nl' |
| Number | 50, -5, 2.5 |
| Boolean and null | true, false, null |
| List | ['paid', 'enterprise'], [1, 5] |
| Version | semver('5.5.0') |
| Pattern | /^(nl|be)-/i |
- Text is in single quotes. Inside it,
\'is a quote,\\a backslash,\nand\ta line break and a tab, and\ufollowed by four hexadecimal digits any character. Double quotes are a lint error, so YAML's double quotes can wrap a whole expression when needed. - Numbers are written as in JSON, such as
10,-5,2.5and1e3. - A list holds one type, text or numbers, with
nullallowed beside either. Lint warns about a mix. semver(...)makes a comparison compare versions, soversion >= semver('5.5.0')puts5.10.0after5.9.0. Without it,==and!=compare text, and lint warns when the attribute hasformat: semver.- A pattern follows
matchesornotMatches, with its flags after the closing/. Write a/inside it as\/. - Timestamps are text, compared with
beforeandafter, such assignupDate after '2026-01-01T00:00:00Z'.
Operators in expressions#
Each operator from Operators, in the order of its list, with the example there written as an expression:
| Operator | Expression |
|---|---|
equals | country == 'nl' |
notEquals | country != 'nl' |
greaterThan | account.seats > 10 |
greaterThanOrEquals | account.seats >= 50 |
lessThan | account.seats < 5 |
lessThanOrEquals | account.seats <= 1 |
contains | account.locale contains 'NL' |
notContains | account.locale notContains 'GB' |
startsWith | account.locale startsWith 'nl-' |
endsWith | account.locale endsWith '-BE' |
in | plan in ['paid', 'enterprise'] |
notIn | plan notIn ['enterprise'] |
includes | permissions includes 'write' |
notIncludes | permissions notIncludes 'admin' |
includesAny | permissions includesAny ['write', 'admin'] |
notIncludesAny | permissions notIncludesAny ['write', 'admin'] |
matches | account.locale matches /^(nl|be)-/i |
notMatches | account.locale notMatches /^en-/ |
before | signupDate before '2026-01-01T00:00:00Z' |
after | signupDate after '2026-01-01T00:00:00Z' |
semverEquals | version == semver('5.5.0') |
semverNotEquals | version != semver('5.4.1') |
semverGreaterThan | version > semver('5.5.0') |
semverGreaterThanOrEquals | version >= semver('5.5.0') |
semverLessThan | version < semver('5.0.0') |
semverLessThanOrEquals | version <= semver('5.4.0') |
exists | userId exists |
notExists | userId notExists |
isNull | account isNull |
notNull | account notNull |
empty | permissions empty |
notEmpty | permissions notEmpty |
Presence and emptiness operators take no value and follow the name. disallowOperators and allowedConditions apply to expressions as they do to trees.
Missing values#
An expression matches a missing value exactly as its tree does. With the attribute missing, every comparison returns false except != and notExists. Missing values explains each case.
not turns any answer around, a missing value's too. So not plan in ['enterprise'] matches a context without a plan, while plan notIn ['enterprise'] does not. not applies to the one condition after it, so fmt writes it without parentheses there. Lint warns where the two differ:
description: Accounts on any plan but enterpriseconditions: not plan in ['enterprise']Lint warns that not here also matches when plan is missing, unlike notIn. Its hint offers both: plan notIn ['enterprise'] to leave a missing plan out, or plan notIn ['enterprise'] or plan notExists to keep it in. Write either, and the warning goes.
Find mistakes#
Lint reports a mistake in an expression at the file, line and column, under the part it is about. A misspelt segment in checkout:
description: Checkout redesignbucketBy: userIdrules: - key: netherlands conditions: | netherland and payingAccounts percentage: 100 - key: everyone conditions: "*" percentage: 25=> Error: Unknown segment "netherland". Did you mean "netherlands"? features/checkout.yml:8:7 netherland ^^^^^^^^^^Hints point at the usual slips:
country = 'nl'takes==to compare.plan == "paid"takes single quotes.planalone, whereplanis an attribute, takes a comparison, such asplan == 'paid'.liveChatalone, whereliveChatis a feature, takesfeature('liveChat').variable('maintenanceMode')alone takes a comparison, such asvariable('maintenanceMode') == false.country in ('nl', 'de')takes square brackets,country in ['nl', 'de'].feature('liveChat').enabledreads a variable namedenabled.feature('liveChat')alone is the enabled state.seats between 1 and 5takes both ends,seats >= 1 and seats <= 5.
Format expressions#
fmt writes expressions in one style, in a project that allows them. It only rewrites an expression where the result builds exactly the same datafile, so build writes the same bytes before and after. It never turns a tree into an expression, or an expression into a tree. That is what convert does.
An expression with more than one condition gets one condition a line:
description: File upload inside chatbucketBy: userIdconditions: feature('liveChat') and not feature('pricing').variation == 'treatment'rules: - key: everyone conditions: "*" percentage: 100becomes:
description: File upload inside chatbucketBy: userIdconditions: | feature('liveChat') and not feature('pricing').variation == 'treatment'rules: - key: everyone conditions: "*" percentage: 100Put # featurevisor-ignore-line on a conditions: line to keep its expression as written. fmt --simplify also writes each condition in its simplest form, joining expressions that sit side by side into one.
Test and explain#
Test specs and evaluate work unchanged, since they see the tree an expression builds. In a project that allows expressions, evaluate --explain prints conditions as expressions, then checks them part by part:
npx featurevisor evaluate \ --feature=chatFileUpload \ --context='{"userId":"user-4"}' \ --explainFor chatFileUpload it prints feature('liveChat') and not feature('pricing').variation == 'treatment', then a pass or fail for each part. user-4 is in pricing's control, so the not passes and file upload is enabled.
Details#
Names#
- Names use the characters of a key: letters, digits,
_,-,.and/, plus your project'snamespaceCharacter. A namespaced segment, such ascountries.netherlands, is one name. feature,variableandsemverare calls only when(follows, andcontains,existsand the other operator words are operators only after a name. An attribute or segment with one of those names still works.- After
feature(...)orvariable(...), write a key with unusual characters, or an array item, in brackets:variable('checkoutSettings')['limits.v2'][0].quotareads the first item'squotaunder the literallimits.v2property. and,or,not,true,falseandnullare words of the language, so an attribute or segment with one of those keys cannot be named inside an expression. Write that condition as a tree.
What expressions cannot say#
Expressions say only what a tree can. There is no arithmetic, no comparison between two attributes or between an attribute and a variable, and nothing computed from a clock. A feature read with feature(...) sees results, never the feature's own settings.
The tree's leaf for a disabled feature's variation, { feature: pricing, enabled: false, variation: control }, has no expression form. Keep it as a tree item in a list beside your expressions.
YAML quoting#
An expression never starts with a character YAML reads specially, so most need no quotes. A space followed by # starts a YAML comment, so text containing #, such as tag == ' #sale', needs the whole expression in double quotes or a | block. A string starting with [ or { is read as JSON conditions, never as an expression.
Limits#
An expression may be up to 10,000 characters long and nest groups up to 64 deep.

