Define your configuration
Variables
Variables are typed values your application reads at runtime, such as a button label, a cart limit or a support address. Change them in a pull request and applications pick them up without being released again. Attach one to a feature when it should follow that feature's rollout, or define a global variable when it has its own lifecycle.
Choose a kind#
There are two kinds of variable: global and feature variables. Ordinary definitions share the same types, schemas and mutations. A global variable can also compose evaluated values instead of declaring its own default, then override the result.
Global variable
Stands on its own, like a support address or checkout settings. The context alone decides its value, with no bucketing or percentages.
Feature variable
Belongs to one feature, like the label and colour of a button. It follows that feature's rollout and rules, and can differ by variation.
If the value should roll out gradually or take part in an experiment, make it a feature variable. Otherwise, start with a global one.
Types#
Arrays and objects describe their items and properties, as checkoutSettings does below. Schemas covers constraints, oneOf branches and reusable schemas.
Global variables#
A global variable stands on its own, with no feature around it. It has no bucketBy or percentages, so the same context and datafile always give the same value. This section builds supportEmail a step at a time, then moves to checkoutSettings for structured values.
Define a global variable#
Each global variable is a YAML file in variables/. The file name is its key, so this file declares supportEmail:
description: Support email shown in checkouttype: stringWith no overrides yet, every context gets the default:
const context = { userId: 'user-9', country: 'de',}An ordinary global variable takes these fields. Composed definitions use a separate shape:
| Field | Required | Description |
|---|---|---|
type | Yes | One of the types, unless oneOf or a reusable schema describes the value. |
defaultValue | Yes | The value when no override matches. |
description | Yes | What the variable controls. |
overrides | No | Values for some contexts, checked in order. |
conditions | No | Gate the variable with attribute, segment, feature or variable leaves. |
disabledValue | No | The value while requirements are unmet, in place of null. |
useDefaultWhenDisabled | No | Set to true to return defaultValue while requirements are unmet. disabledValue wins when both are set. |
tags | No | Lets a target select the variable. |
deprecated | No | Set to true for SDK deprecation diagnostics, subject to logLevel. |
archived | No | Leaves the variable out of every datafile. |
promotable | No | Protects it, or a single override, from promotions. |
Examples of types#
One complete global variable for each type, declared the same way with a defaultValue of that type. Link to a type's heading to point someone at its example.
string#
Text, such as a support address:
description: Support email shown in checkouttype: stringboolean#
A switch, such as the kill switch that Conditions checks:
description: Maintenance kill switchtype: booleandefaultValue: falseinteger#
A whole number:
description: Most items allowed in the carttype: integerdefaultValue: 50number#
A number with decimals:
description: Order total that ships for freetype: numberdefaultValue: 49.99array#
A list of values, with items describing each one:
description: Countries the checkout ships totype: arrayitems: type: stringdefaultValue: - nl - deobject#
Named keys in properties, each with its own type:
description: Free shipping offertype: objectproperties: minimumOrder: type: number currency: type: stringdefaultValue: minimumOrder: 49.99 currency: EURHold a structured value builds a larger object with an array inside it.
json#
A JSON string that Featurevisor does not check. Prefer object when you know the shape:
description: Payment provider configtype: jsondefaultValue: '{"provider": "adyen", "retries": 3}'Read them#
Typed getters return each value with its type, or null when the value has another:
const context = { country: 'de',}f.getVariableBoolean('maintenanceMode', context) // falsef.getVariableInteger('maxCartItems', context) // 50f.getVariableNumber('freeShippingThreshold', context) // 49.99f.getVariableArray('checkoutCountries', context) // ['nl', 'de']f.getVariableObject('freeShipping', context)f.getVariableJSON('paymentProviderConfig', context)The last two return { minimumOrder: 49.99, currency: 'EUR' } and { provider: 'adyen', retries: 3 }. Schemas covers constraints, oneOf and reusable schemas for every type.
Override it for an audience#
Add an override to give some contexts a different value. This is the supportEmail variable from the Quick start:
description: Support email shown in checkouttype: stringoverrides: - key: netherlands conditions: netherlandsDutch users now get [email protected], and everyone else keeps the default. Each override needs a unique key, something to match on, and a value.
Match attributes directly#
To select an audience without naming a segment, write an attribute leaf in conditions. Belgian users get their own address:
# ...overrides: - key: belgium conditions: - attribute: country operator: equals value: beConditions use the same grammar as a segment. Mix attribute, segment, feature and variable leaves in one expression. When the same audience appears in a second place, make it a segment.
Order overrides#
Overrides are checked in order, and the first match wins. Add one for paying accounts, using payingAccounts from Segments:
# ...overrides: - key: priority conditions: payingAccounts - key: netherlands conditions: netherlandsA paying Dutch account matches both overrides. priority comes first, so it gets the priority address. Swap them and it would get [email protected] instead.
A catch-all override with conditions: '*' matches every context, so linting requires it to be last. If you configure environments, put the list of overrides under each environment key.
Depend on features#
An override can match on a feature instead of an audience. Users who have the new checkout from Features get the checkout team's address:
# ...overrides: - key: priority conditions: payingAccounts - key: netherlands conditions: netherlands - key: newCheckout conditions: - feature: checkoutcheckout is on for every Dutch user and a quarter of everyone else, so different users get these values:
| User | Context | supportEmail |
|---|---|---|
user-2 | Dutch, paid plan | [email protected] |
user-4 | Dutch, free plan | [email protected] |
user-5 | German, free plan, in 25% | [email protected] |
user-9 | German, free plan, outside | [email protected] |
conditions takes the same forms as in a feature, so an override can also require a variation, or a feature to be off.
Pair an audience with a feature#
Combine a segment and a feature in one conditions list. Both must match. This gives Dutch users of the new checkout their own address:
# ...overrides: - key: dutchCheckout conditions: - netherlands - feature: checkoutGerman users of the new checkout skip it, because they are not in netherlands.
Depend on a variable value#
conditions matches on another variable's value instead of a feature. During maintenance, everyone gets the status page address, using the maintenanceMode switch:
# ...overrides: - key: maintenance conditions: - variable: maintenanceMode operator: equals value: true - key: priority conditions: payingAccountsIt comes first, so it wins over every other override while maintenanceMode is true. Add feature to check a feature variable instead, and path to read inside a structured value. See Conditions for every operator.
Gate the whole variable#
Requirements at the root of the file apply to the variable itself, not to one override. This help text only makes sense in the new checkout:
description: Help text in the new checkouttype: stringdefaultValue: Need help? Chat with usconditions: - feature: checkoutUsers who have the new checkout get the text. Everyone else gets null, and the evaluation reports conditions_unmet. Give them a value of their own with disabledValue:
# ...conditions: - feature: checkoutdisabledValue: Need help? Email usSet useDefaultWhenDisabled: true instead to give them the defaultValue. When both are set, disabledValue wins.
Add a variable leaf to the same root list. Both conditions must match, so this help text also goes while maintenanceMode is on:
# ...conditions: - feature: checkout - variable: maintenanceMode operator: equals value: falsedisabledValue: Need help? Email usCompare with false rather than putting equals: true in a not group, so that a missing maintenanceMode also keeps the text off. An unmet root gate reports conditions_unmet. See Conditions for groups and the rest of the syntax.
Hold a structured value#
A variable can hold an array or an object. checkoutSettings describes its properties, and the default sets them all:
description: Checkout settingstype: objectproperties: currency: type: string paymentMethods: type: array items: type: stringdefaultValue: currency: EUR paymentMethods: [card]Change part of a value#
An override with value replaces the whole object. Use mutate instead to change some paths and keep the rest. Dutch users get local payment methods, and their currency stays EUR:
# ...overrides: - key: netherlands conditions: netherlands mutate: paymentMethods: - ideal - paypalEach override takes exactly one of value or mutate. Feature variable overrides take mutate too, and resolve into complete values during the build. Global variable mutations follow precomputeMutations. See Mutations for paths and array operations such as appendUnique and removeValue.
Write mutate as a list when order matters or a path needs to appear more than once.
Nested overrides#
An override can contain overrides of its own. Use nesting when a broad match sets a value and a narrower match refines it, such as a country and then a city. Here the Dutch override from the previous step gains a child for Amsterdam:
# ...overrides: - key: netherlands conditions: netherlands mutate: paymentMethods: - ideal - paypal overrides: - key: amsterdam conditions: - attribute: city operator: equals value: amsterdam mutate: paymentMethods:appendUnique: cashA child is checked only after its parent matches, and its mutate starts from the parent's value. With a string city attribute, different users get these values:
| Context | paymentMethods | currency |
|---|---|---|
| German | [card] | EUR |
| Dutch, in Rotterdam | [ideal, paypal] | EUR |
| Dutch, in Amsterdam | [ideal, paypal, cash] | EUR |
The first matching child wins, and the parent's value applies when none matches. Keys must be unique across the whole tree. Detailed evaluations report the final variableOverrideKey and its variableOverridePath, such as ['netherlands', 'amsterdam'].
Accumulating overrides#
By default, the first matching override wins. Add continue: true to keep going, so later matching overrides in the same list change the value further. Detailed evaluations then list every matched step in variableOverridePaths. See the worked value sequence for an example.
Tags and targets#
Global variables take tags like features do. A target that selects by tag includes only the global variables that carry its tag, so tag each one with every application that reads it. With the applications' tags declared in configuration, keeping any other settings you have:
module.exports = { tags: ['web', 'mobile'],}Tag the checkoutSettings definition above:
# ...tags: - web - mobileThe web target from Targets now includes it, and so does mobile:
description: Web apptag: webA target can also select global variables by key with includeVariables and excludeVariables. See Narrow by key.
Archiving and deprecating#
Retire a global variable in two steps. First set deprecated: true. It stays in datafiles. SDK evaluations can emit a deprecation diagnostic, subject to logLevel, so you can find code that still uses it. Lint warns where another definition still refers to it:
deprecated: true# ...Once nothing reads it, set archived: true instead. It is left out of every datafile, and linting stops an active feature, variable, segment, spec or target from still referring to it. Lint checks an archived variable for its shape only, without resolving what it refers to. Keep the file rather than deleting it, so the key is not reused with another meaning:
archived: true# ...Compose values#
Upgrade consumers before publishing
Upgrade every consumer to a v4 SDK before publishing compositions, including applications using cached and bundled datafiles.
A composed global variable builds its value from other values instead of declaring its own. Use it to give a screen everything it needs in one read, to rename a variable without breaking applications that still read the old key, or to adjust a shared value for one consumer.
Alias another global variable under a new key with a string compose reference:
description: Support address for checkoutcompose: supportEmailsupportEmail is a source key, not a literal result. checkoutSupport returns that global variable's evaluated value directly, without wrapping it in an object. Read it with the source's getter:
const context = { userId: 'user-9', country: 'nl',}This uses the initial supportEmail definition above, before adding audience overrides.
The result inherits the source type. It can be a string, boolean, number, array, object or decoded JSON value. Read it with the same getter you use for the source. Source targeting, requirements, disabled behaviour, sticky values and modules still apply. A null source stays null. JSON is decoded exactly once, including through chains of aliases.
A string reference names only a global variable. Dots, slashes and other namespace separators are literal parts of its complete key, not paths or feature variable syntax. For example, compose: checkout/supportEmail reads the global key checkout/supportEmail.
Use an alias when one consumer needs a separate key or its own overrides while sharing the source's behaviour. Modify the original variable when the change belongs to every consumer.
To rename a variable, move its definition to newKey.yml and leave this alias at the old key while consumers migrate:
description: Previous variable keycompose: newKeydeprecated: trueUpdate consumers to read newKey, then remove the old alias once nothing reads it. A deprecated alias uses the normal SDK deprecation diagnostics.
Each evaluation of a deprecated source can emit its normal deprecated_variable diagnostic, subject to logLevel, including through an alias or map composition. A whole alias sticky value bypasses its source, so that source is not evaluated. Deprecating the source does not distinguish old callers from new ones, so deprecate the old alias during a rename instead.
Collect several values#
Collect flags, variations and variables into one global object. Each key in the compose map names a property in the result, and its reference selects the getter to evaluate:
description: Values for the checkout viewcompose: enabled: { feature: checkout } pricing: { feature: pricing, variation: true } label: { feature: buyButton, variable: label } support: { variable: supportEmail }The four forms read a flag, a variation, a feature variable and a global variable. Use variation: true to read the selected variation, not a named variation. That source must declare variations or a disabledVariationValue. Values come from each source's normal getter, including its targeting, bucketing, requirements and disabled fallbacks.
Read the result with the existing global variable getter:
const context = { userId: 'user-9', country: 'nl',}const view = f.getVariableObject('checkoutView', context)With the initial checkout, pricing, buyButton and supportEmail definitions on their respective pages, this gives { enabled: true, pricing: 'control', label: 'Buy', support: '[email protected]' }. A false flag or null member stays in the object and does not gate its siblings. A JSON source is decoded, rather than returned as its stored string.
Every member is eagerly evaluated before any overrides run. An experiment exposure may occur even when your application does not render that member or an override later replaces it. Only compose values consumed together. Read conditional experiments separately and put audience targeting on the original experiment, not on the composition's overrides. See module dependency reads for tracking.
A composed definition requires description and either a nonempty global source key or a nonempty compose map. Optional root conditions gate the whole result. It also accepts disabledValue, tags, deprecated, archived, promotable and overrides. Do not add root type, schema fields, defaultValue, useDefaultWhenDisabled or mutate. The sources define the result schema, and lint validates disabledValue against that inferred schema.
Property names cannot be empty or equal __proto__, prototype or constructor. Dots and slashes in a property name are literal, not paths. Use a single reference form per property, without literal values or fallback fields.
Nest compositions#
A global reference can point to another composition. Its object remains under the property rather than being flattened:
description: Checkout page configurationcompose: view: { variable: checkoutView } settings: { variable: checkoutSettings }References must be acyclic, including paths through source requirements. Lint rejects cycles. Keep dependency chains within the SDK's limit of 256 nested dependency evaluations. A cycle or exceeded limit fails the whole evaluation with reason error, rather than returning a partial object.
String aliases can also reference other aliases or map compositions. Targets include their complete source dependency chain, even when those sources have different tags. Changes to a source affect dependent aliases' hashes and change notifications too.
Override an alias#
Start from the source's evaluated value, then apply ordinary keyed overrides. Alias checkoutSettings and add iDEAL for Dutch users. Independently, offer invoice payments to paid and enterprise accounts. This uses the plan attribute:
description: Regional checkout settingscompose: checkoutSettingsoverrides: - key: netherlands conditions: netherlands continue: true mutate: paymentMethods:appendUnique: ideal - key: paying conditions: - attribute: plan operator: in value: - paid - enterprise mutate: paymentMethods:appendUnique: invoiceDutch users on the free plan get paymentMethods: ['card', 'ideal']. With plan: 'paid', they get ['card', 'ideal', 'invoice'] because continue: true reaches the independent paying override.
German users on the paid plan get ['card', 'invoice']. German users on the free plan keep ['card']. Enterprise accounts get the same invoice option as paid accounts.
Currency stays EUR for everyone. Without continue: true, Dutch users stop after the iDEAL append and miss the invoice option even on the paid plan. The source value remains unchanged.
Overrides match the inferred source shape, not an object with a source key property. A whole value replacement must match that shape too. Requirements on individual overrides can guard a nullable source, but do not change its generated type. See result ownership before changing a returned object.
Gate a composition#
Gate checkoutPage before reading its sources. This definition returns a schema checked fallback when checkout is off:
description: Checkout page configurationconditions: - feature: checkoutcompose: view: { variable: checkoutView } settings: { variable: checkoutSettings }disabledValue: view: null settings: nullWithout disabledValue, an unmet gate returns null. It never reads checkoutView or checkoutSettings and never runs overrides. A whole variable sticky value bypasses the gate too. Compositions have no defaultValue or useDefaultWhenDisabled.
Override composed values#
A whole variable sticky value wins first. Root conditions run next, before any source getter. A gate miss returns disabledValue when supplied, otherwise null, and skips all sources and overrides. When the gate matches, all sources are evaluated before the ordered keyed overrides. Extend checkoutPage with a regional currency and an extra payment method when checkout is enabled:
description: Checkout page configurationcompose: view: variable: checkoutView settings: variable: checkoutSettingsoverrides: - key: netherlands conditions: - attribute: country operator: equals value: nl continue: true mutate: settings.currency: USD overrides: - key: checkout conditions: - feature: checkout mutate: settings.paymentMethods:appendUnique: ideal - key: support conditions: - variable: supportEmail operator: equals mutate:For the same Dutch context, the result has settings.currency: 'USD', settings.paymentMethods: ['card', 'ideal'] and view.support: '[email protected]'. The source checkoutSettings and checkoutView values remain unchanged. The parent continues to the support sibling after its nested override runs.
Each override requires a unique key, a conditions expression and exactly one of value or mutate. Use conditions: '*' to always match. Nested overrides also require their ancestors to match, and continue follows the ordinary global override rules.
The inferred property set and source schemas still define the output. A whole value replacement must include exactly those properties and conform to their types, including nullable source values. Mutations cannot remove required root properties or add arbitrary properties.
The advisory composition_experiment_override warning means an override may change an experiment source value, so prefer changing the original experiment.
The advisory composition_nullable_mutation warning means a mutation may traverse a null source, so add a source fallback or a guarding requirement.
Mutation notation remains the existing path strings. A literal dotted property cannot be addressed as one key by mutate. Use a simple property name or replace the whole value, keeping the dotted property as a literal key.
Feature variables#
A feature variable lives inside a feature and follows it. Most need nothing more than the feature's rollout and rules: a value everyone gets, and different values for some audiences. Variations are an optional step for experiments. This section builds one feature, buyButton, a step at a time.
Declare a feature variable#
Declare each variable in variablesSchema, with a type and a default value:
description: Buy button copy and colourbucketBy: userIdvariablesSchema: label: type: string defaultValue: Buy colour: type: string defaultValue: '#111827'rules: - key: everyone conditions: '*' percentage: 100The feature is on for everyone, so everyone gets the defaults:
const context = { userId: 'user-9', country: 'de',}f.getVariable('buyButton', 'label', context) // 'Buy'Each declaration takes these fields:
| Field | Required | Description |
|---|---|---|
type | Yes | One of the types, unless oneOf or a reusable schema describes the value. |
defaultValue | Yes | The value when nothing overrides it. |
description | No | What the variable controls. |
disabledValue | No | The value while the feature is disabled. Without it, the getter returns null. |
useDefaultWhenDisabled | No | Set to true to return defaultValue while the feature is disabled. disabledValue wins when both are set. |
deprecated | No | Set to true for SDK deprecation diagnostics, subject to logLevel. |
While the feature is disabled#
Roll buyButton out to half of everyone instead. Users outside the rollout get null for every variable, unless the declaration says otherwise. Here they keep the default label and get a grey button:
# ...variablesSchema: label: type: string defaultValue: Buy useDefaultWhenDisabled: true colour: type: string defaultValue: '#111827' disabledValue: '#6b7280'rules: - key: everyone conditions: '*' percentage: 50The examples below are back at 100%.
Change values in a rule#
Rules decide who gets the feature. They can also change its values. Add a rule that gives Dutch users a translated label:
# ...rules: - key: netherlands conditions: netherlands percentage: 100 variables: label: Nu kopen - key: everyone conditions: '*' percentage: 100Dutch users now see Nu kopen. Only label changes, so their button keeps the default colour. Everyone else matches the everyone rule and gets both defaults.
Narrow a rule with overrides#
A rule's variables apply to everyone it matches. To change a value for only some of them, add variableOverrides. They are checked in order, and the first match wins. Here the paying accounts among Dutch users also get a green button:
# ...rules: - key: netherlands conditions: netherlands percentage: 100 variables: label: Nu kopen variableOverrides: colour: - key: paying conditions: payingAccounts value: '#047857' - key: everyone conditions: '*' percentage: 100payingAccounts is a segment from Segments. Use attribute leaves in conditions to match context directly, and mutate in place of value to change part of an object.
Each override can have a key, such as paying. Keys are optional for feature variables, but recommended. The key appears as variableOverrideKey in detailed SDK evaluations, and lets promotions protect a single override. Set requireOverrideKeysInFeatures to require them.
Add variations#
So far, everyone a rule matches gets the same values. To test which label sells better, add variations. Each one can set its own values:
# ...variations: - value: control weight: 50 - value: treatment weight: 50 variables: label: Buy now colour: '#2563eb'Users in control keep the defaults, and users in treatment see Buy now in blue. A rule's values still win over a variation's, so Dutch users keep Nu kopen in both variations.
Narrow a variation with overrides#
A variation takes variableOverrides the same way a rule does. Within treatment, paying accounts see a different label:
# ...variations: - value: control weight: 50 - value: treatment weight: 50 variables: label: Buy now colour: '#2563eb' variableOverrides: label: - key: paying conditions: payingAccounts value: Add to orderPutting it together#
Here is buyButton with every step above:
description: Buy button copy and colourbucketBy: userIdvariablesSchema: label: type: string defaultValue: Buy colour: type: string defaultValue: '#111827'variations: - value: control weight: 50 - value: treatment weight: 50 variables: label: Buy now colour: '#2563eb' variableOverrides: label: - key: paying conditions: payingAccounts value: Add to orderrules: - key: netherlands conditions: netherlands percentage: 100 variables: label: Nu kopen variableOverrides: colour: - key: paying conditions: payingAccounts value: '#047857' - key: everyone conditions: '*' percentage: 100Each value comes from the first of these that sets it:
- A force entry's
variables - The matched rule's
variableOverrides - The matched rule's
variables - The variation's
variableOverrides - The variation's
variables defaultValue
So different users get these values:
A sticky value comes before every step, and a disabled feature skips them all. The evaluation flow lists each step.
Requirements in overrides#
An override can also depend on another feature or a variable value. This shows a different label to users in the treatment variation of the pricing experiment:
# ...rules: - key: everyone conditions: '*' percentage: 100 variableOverrides: label: - key: newPricing conditions: - feature: pricing variation: treatment value: See new pricesSee Conditions for groups and variable checks. Feature variable overrides are a flat list. They cannot nest or use continue, which global variables support. See the experiments guide for more on testing with variations.
Read in your application#
With a loaded SDK instance, pass the variable key alone for a global variable, or the feature and variable keys for a feature variable:
const context = { userId: 'user-2', country: 'nl',}f.getVariable('supportEmail', context)f.getVariable('buyButton', 'label', context)Typed getters such as getVariableString and getVariableObject return null instead of converting a value of another type. See the JavaScript SDK for every getter, evaluating all global variables at once, and passing them on as sticky values.
Test and inspect#
Test global variables in their own specs under tests/variables/, as Testing shows. Test feature variables with expectedVariables in a feature spec.
To see why a context gets a value, add --explain to evaluate. It shows selected override paths and available value or mutation declarations, not a complete execution trace:
npx featurevisor evaluate \ --variable=checkoutSettings \ --context='{"country":"nl","city":"amsterdam"}' \ --explainnpx featurevisor lint checks every value and override against its schema. See CLI explanations and mutation linting.
Details#
Composed override execution#
Composed overrides always compile to overrideProgram, even with precomputeMutations: true or literal replacements only. Their starting value comes from evaluated sources, so the builder cannot precompute it. No match reports variable_composed. A match reports variable_override_rule with the existing key, path and ordered matched paths.
There is no static result preview for a composition. Evaluate it with the intended context to inspect the SDK outcome. In --explain output, composition member values come from the final result after any overrides, not a saved copy of source getter values. The explanation does not replay the starting object or intermediate mutation values.
JSON sources are decoded at runtime, so their member value type is unconstrained rather than the stored JSON string type. Lint checks what can be established statically, not arbitrary module outputs or every runtime array constraint.
Composed result ownership#
Map composition creates a fresh plain outer object, including when an override replaces the whole value. Its member arrays and objects are borrowed like normal getter results, not deep copied. A string alias returns the ordinary source getter value without an automatic root or deep copy. An alias's whole literal replacement is borrowed like an ordinary variable value too. Treat borrowed results as read only or copy them before changing them.
Mutations copy modified paths without changing source values or another property or alias borrowing the same source. A whole variable sticky value bypasses both source evaluation and overrides and returns reason sticky. Existing sticky and module value ownership is unchanged.
See module dependency reads for memoization and why composition does not promise an atomic snapshot. Use composition tests to check source and sticky values.
Nullable composition members#
Generated TypeScript types keep variable and variation members nullable, including nested compositions. A source definition or default does not prove its getter always returns a nonnull value. Errors, modules and missing runtime data can still return null. Keep null checks in application code even when an override uses a notNull requirement. Overrides do not narrow the generated types.
A string alias inherits the source's evaluated type with conservative null support. Its entire result is recursively read only, including when the source is another alias or map composition. JSON aliases describe the decoded result, not the stored string.


