Skip to Content
FeatureWriting a feature down

Configuration

@evanion/feature 0.1.0 and its patch releases up to 0.1.1, the version on npm. Values on this page were produced by running the source at e53b2d8, a later commit than the 0.1.0 tag, and are not re-executed.

To move your feature flags out of component logic and into a central store, you use createFeatures. This function takes your feature definitions, validates them, and returns the store every other call reads. A rule in a definition replaces the role === 'bookseller' check each component would otherwise repeat.

Declarative Feature Definitions

@evanion/feature uses a declarative configuration so you can define the intent (is it enabled?) and the rules (when is it active?) in a single place. createFeatures validates the dependency graph, deep-clones and freezes your definitions, and returns the store.

import { } from '@evanion/feature'; const = [{ : 'gift-cards', : true }]; const = (); .('gift-cards'); // -> true .([0]); // -> false .(.('gift-cards')); // -> true

The store holds a frozen copy of each definition. Your own array stays editable, and an assignment to the copy definition() returns throws a TypeError, so the store never diverges from what it resolves.

The Configuration Schema

FieldMeaning
keyThe unique identifier for the feature. A string or a number.
enabledThe kill switch. If false, the feature is off for everyone.
dependsOnA list of other features that must resolve on for this one to activate.
rulesActivation rules (OR-ed). If any rule matches, the feature is active.
seedA bucketing seed for rollouts. Defaults to the key.
freezeTimeAtBuildIf true, plan() resolves time windows at build-time for static sites.
variantsThe named variants this feature splits across (see Variants).
variantByThe context field used to bucket variants. Defaults to targetingKey.
variantSeedBucketing seed for the variant split. Defaults to the seed (or key) plus :variant.

A rule carries an optional id, a when list of conditions, an optional rollout, and an optional variant it pins. A condition names a context field, an op and a value.

Resolution Precedence

When you call resolve(), @evanion/feature follows a strict hierarchy to determine if a feature is active.

  1. The Kill Switch: If enabled === false, the feature is off. No rules are evaluated.
  2. The Dependency Chain: If any feature in dependsOn resolves to false, this feature is off. No rules are evaluated.
  3. The Default State: If enabled === true and there are no rules, the feature is on.
  4. The Rules: If rules exist, they are OR-ed. If any rule matches, the feature is on. Inside a rule, conditions are AND-ed.

enabled does not depend on rule order. rule names the first rule that matched, and that rule’s variant pin is the one applied, so moving a rule up can change which rule a decision names.

The kill switch works in one direction only. Setting enabled: true on a feature whose rules do not match changes the stored intent, and the feature still resolves off.

Dependency Cascades

@evanion/feature cascades dependencies transitively and one way. With demo night booking behind express pickup, and express pickup behind the new checkout, express pickup being off takes demo night booking off and leaves the new checkout on.

import { } from '@evanion/feature'; const = ([ { : 'demo-night-booking', : true, : ['express-pickup'] }, { : 'express-pickup', : false, : ['new-checkout'] }, { : 'new-checkout', : true }, ]); .('demo-night-booking'); // -> false .('new-checkout'); // -> true

createFeatures evaluates in dependency order, so the cascade works at any depth and you can declare your features in any order.

Configuration Errors

createFeatures validates the dependency graph at construction. It throws a subclass of FeatureConfigError if it detects:

  • Cycles: FeatureCycleError, e.g., A depends on B, and B depends on A. Its path property holds the cycle’s keys, from the first key back to itself.
  • Unknown Dependencies: UnknownDependencyError, when a feature depends on a key that isn’t configured.
  • Duplicates: DuplicateFeatureError, when two features share the same key.
import { } from '@evanion/feature'; import type { } from '@evanion/feature'; const = (: []) => { try { (); return 'built'; } catch () { return (); } }; const : [] = [ { : 'a', : true, : ['c'] }, { : 'b', : true, : ['a'] }, { : 'c', : true, : ['b'] }, ]; const : [] = [ { : 'a', : true }, { : 'a', : true }, ]; (); // -> 'FeatureCycleError: feature dependency cycle: a -> c -> b -> a' ([{ : 'a', : true, : ['b'] }]); // -> 'UnknownDependencyError: feature "a" depends on "b", which is not configured' (); // -> 'DuplicateFeatureError: duplicate feature key "a"'

Variants are checked at the same point: DuplicateVariantError for two variants sharing a name, and UnknownVariantError for a rule pinning a variant its feature does not declare. Once createFeatures returns, resolve, plan and toggle never throw.

Defining Rules and Conditions

To activate features based on the user’s context, you use @evanion/feature rules. In the example below, a bookseller matches the first rule, and every other customer is subject to the second rule’s 10% rollout:

import { } from '@evanion/feature'; const = ([ { : 'beta-banner', : true, : [ { : 'internal', : [{ : 'roles', : 'contains', : 'bookseller' }], }, { : 'ramp', : { : 10 } }, ], }, ]); .('beta-banner', { : ['bookseller'] }); // -> true const = (: string) => .('beta-banner', { : ['customer'], }); ('cust-0042'); // -> false ('cust-0007'); // -> true

A rule that declares no id is named by a hash of what it matches on, so its name survives a rule being inserted above it. A decision names the rule in rule, and in its breakdown when no rule matched. The hash leaves out rollout.percent, so two rollout rules on one feature that differ only in their percentage share a name. Give a rule you expect to read in a log an explicit id.

Condition Operators

opEffectExample
before / afterChecks if now is strictly before/after an instant.value: '2026-10-01T00:00:00Z'
day-of-weekChecks if now falls on specific weekdays in a zone.zone: 'Europe/Stockholm', value: ['mon']
eq / neStrict equality or inequality.value: 'bookseller'
in / not-inChecks if the value is in/not in an array.value: ['bookseller', 'owner']
containsChecks if the field (array) contains the value.value: 'bookseller'

A day-of-week condition has no default zone: it names an IANA zone, because UTC day-of-week is wrong for a shop open in Stockholm.

Missing Context Fields

In @evanion/feature, a condition over a field the context does not carry never holds, and that includes ne and not-in. Treating ne on an absent field as true would switch features on for exactly the contexts that carry the least information.

import { } from '@evanion/feature'; const = ([ { : 'new-checkout', : true, : [ { : 'not-customers', : [{ : 'role', : 'ne', : 'customer' }], }, ], }, ]); .('new-checkout', { : 'bookseller' }); // -> true .('new-checkout', {}); // -> false

Time Windows

To read the same on every host, a date-time string must include an offset (e.g., +09:00, +0900 or Z). A date with no time is read as UTC midnight. createFeatures rejects a date-time without an offset, and a date the calendar does not have, at construction.

import { } from '@evanion/feature'; const = (: string) => { try { ([ { : 'new-checkout', : true, : [{ : 'launch', : [{ : 'now', : 'after', }] }], }, ]); return 'built'; } catch () { return ( as Error).; } }; ('2026-01-01T09:00:00+09:00'); // -> 'built' ('2026-01-01T09:00:00+0900'); // -> 'built' ('2026-01-01'); // -> 'built' ('2026-01-01T00:00:00'); // -> 'FeatureConfigError' ('2026-04-31'); // -> 'FeatureConfigError'

A window’s value can also be epoch milliseconds or a Date. createFeatures refuses NaN and a number outside the range a Date holds, and it takes a Date as the instant it holds, so new Date('2026-01-01T00:00:00') carries the zone of the host that built it.

Type-Safe Keys and Variants

You can maintain type safety for your @evanion/feature configuration depending on how your data is stored. If you define your configuration as an array literal, TypeScript infers the keys and variant names automatically.

If your configuration comes from a JSON file or variable, you can provide a schema interface:

import { } from '@evanion/feature'; import type { } from '@evanion/feature'; interface Flags { 'new-checkout': never; 'express-pickup': { : 'control' | 'blue'; : never }; } // Typed as definitions, so the compiler has no literals to read. const : <keyof Flags>[] = [ { : 'new-checkout', : true }, { : 'express-pickup', : true, : ['new-checkout'], : [ { : 'control', : 50 }, { : 'blue', : 50 }, ], }, ]; const = <Flags>(); .('express-pickup', { : 'cust-0042' }); // -> 'blue' // @ts-expect-error -- 'express-delivery' is not a key of Flags .('express-delivery'); // -> false

This ensures that calling features.isEnabled('express-delivery') is a compile error, and features.variantOf('express-pickup') returns 'control' | 'blue' | undefined. createFeatures<Flags>(config) also checks every definition’s key against keyof Flags.

Decisions resolves this store and reads what each decision says about the rule that decided it.

Last updated on