These pages document @evanion/feature 0.1.1, the version on npm.
API reference
One entry point. @evanion/feature imports no framework and publishes
everything below. The React binding is a package of its own, and
its API reference has the provider, the hooks and the
types they name.
createFeatures(definitions, options?)
function createFeatures<
const D extends DefinitionsOrConfig,
O extends Record<
Exclude<
keyof O,
keyof FeatureOptions<AsSchema<InferSchema<DefinitionsOf<D>>>>
>,
never
>,
>(
definitions: D,
options?: O & FeatureOptions<AsSchema<InferSchema<DefinitionsOf<D>>>>,
): Features<
AsSchema<InferSchema<DefinitionsOf<D>>>,
'observe' extends keyof O ? true : false
>;
function createFeatures<S extends Record<keyof S, VariantInfo | never>>(
definitions: DefinitionsOrConfig<NoInfer<Extract<keyof S, FeatureKey>>>,
options?: UnobservedOptions<S>,
): Features<S, false>;
function createFeatures<S extends Record<keyof S, VariantInfo | never>>(
definitions: DefinitionsOrConfig<NoInfer<Extract<keyof S, FeatureKey>>>,
options: FeatureOptions<S>,
): Features<S, true>;Validates the dependency graph, deep-clones and freezes the configuration, and returns the store.
The first parameter can be provided as either a bare definitions array or a
whole document. A @evanion/feature author writing four flags in a file passes
the array, while a process whose poller already fetched a document passes that
document without unwrapping it. DefinitionsOf<D> recovers the array out of
either form, which is what keeps the variant names inferred from a literal.
When createFeatures is called over a document, it throws where
parseFeatureConfig reports; both functions call one checker. A store built
from a document lifts the envelope into features.version and
features.envelope.
| Overload | Call shape | What it checks |
|---|---|---|
| First, inferred | An array literal, written inline; no type argument | Keys, variant names and each variant’s value come from the literal. const is what keeps those literal types |
| Second, named | A schema the caller names, e.g. configuration from JSON | createFeatures<Flags>(config) checks every definition’s key against keyof Flags; a key Flags does not declare fails at the call |
A caller who holds the definitions in a variable writes
as const satisfies Definitions<Flag> after the array literal. satisfies
checks the literal and leaves the variable the literal’s own type, so the first
overload reads the variant names off it. The annotation
const definitions: Definitions<Flag> = [...] gives the variable the wider type,
whose variants is optional, and variantOf then answers string | undefined
for every key it covers.
createFeatures throws FeatureCycleError, UnknownDependencyError or
DuplicateFeatureError here; evaluation raises none of them. See
FeatureConfigError and the five classes under it for
what each carries.
How a misspelled option gets caught
The options settle the second type argument. The first signature reads them as
the type parameter O and answers 'observe' extends keyof O ? true : false,
so a call that writes observe at the call site gets the frozen store and a
call that passes no options at all gets the mutable one. O’s constraint,
Record<Exclude<keyof O, keyof FeatureOptions<S>>, never>, names no member of
FeatureOptions and the parameter intersects the two. A type parameter inferred
from an object literal takes the literal’s own type, so the compiler runs no
excess property check of its own, and that constraint is what keeps a misspelled
option an error at all. The compiler anchors that error on the createFeatures
callee and names no property. A call passing { observ: () => undefined }
reports TS2769: No overload matches this call, and the first overload’s line
carries Record<"observ", never> with Type '() => undefined' is not assignable to type 'never' under it. A reader finds the misspelled name inside that printed
signature.
Why the schema-naming case takes two signatures
The pair below it exists because TypeScript infers no type argument once a
caller supplies one. createFeatures<Flags>(config, { observe }) would read O
from its default and answer the unobserved form, so the schema-naming case takes
one signature per answer: UnobservedOptions<S>, which pins observe to
undefined, answers Features<S, false>, and FeatureOptions<S> answers
Features<S, true>.
The list stops at three signatures. TypeScript elaborates every candidate for a failed call while a signature list holds three or fewer, and reports a single candidate against the argument nodes once the list holds more, which put the error for a misspelled option on the definitions array.
Which form a call without a clear answer gets
A call whose options settle neither question answers the frozen form. Options
held in a variable annotated FeatureOptions<S>, and a wrapper that forwards an
optional observe parameter, both hide the observer from the compiler and both
take that branch, so the compiler refuses a write the runtime refuses once the
observer turns out to be there.
A caller who installs no observer gets the mutable form back by leaving
observe out of the options altogether, and satisfies UnobservedOptions<S> on
such an object checks the other members without disturbing that answer. An
options object that names observe and holds undefined in it works only
against the pair that takes a named schema, because the inferring signature asks
whether O names the member at all and answers the frozen form for a value that
names it. A wrapper forwarding an optional observe parameter has no annotation
available at all: { observe } satisfies UnobservedOptions<S> does not typecheck,
since the forwarded parameter’s type is not undefined. Such a wrapper branches
on the parameter and calls createFeatures once per branch, and its own caller
then reads the union of both forms and narrows it.
See Observing for what an observer receives.
Features<S>
S is the schema, so every key position is keyof S & FeatureKey and every
variant position is what that key’s schema entry declares. K below is
keyof S.
The second parameter is Frozen. A store built with an observer freezes what it
emits and answers the caller with that same object, so resolve, plan and
toggle answer the deeply readonly form for such a store and the mutable form
for a store built without one.
Naming Frozen in an annotation
Take this type off createFeatures, and name it in an annotation only where a
declaration demands one. const store = createFeatures([...]) carries the
Frozen the call settled, and store.resolve() then answers one form. The
annotation const store: Features<Flags> = createFeatures([...]) leaves
Frozen at its boolean default and resolve answers the union of both forms,
which assigns to neither Decisions<Flags> nor DeepReadonly<Decisions<Flags>>.
DeepReadonly maps the Date a rule outcome can carry to a ReadonlyDate, and
that one field is what the union splits on. A declaration that must name the
type names the second parameter with it, Features<Flags, false> for a store
built with no observer and Features<Flags, true> for one built with an
observer. A function that takes either store names both parameters and stays
generic over Frozen.
| Member | Returns | Notes |
|---|---|---|
keys | readonly (keyof S & FeatureKey)[] | every key, in configuration order |
config | readonly FeatureDefinition<keyof S & FeatureKey>[] | the stored intent, deeply frozen |
version | string | number | undefined | the installed document’s version |
envelope | ConfigEnvelope | that document’s members, deeply frozen |
definition(key) | FeatureDefinition<keyof S & FeatureKey> | undefined | |
dependants(key) | readonly (keyof S & FeatureKey)[] | transitive, in dependency order |
resolve(context?) | FrozenWhenObserved<Frozen, Decisions<S>> | a decision per feature; writes nothing |
isEnabled(key, context?) | boolean | false for an unknown key |
variantOf(key, context?) | S[K]['variant'] | undefined | undefined for a key declaring no variants |
valueOf(key, context?) | S[K]['value'] | undefined | undefined for a key declaring no variants |
plan(context?) | FrozenWhenObserved<Frozen, Plan<S>> | build-time partition |
for(context?) | BoundFeatures<S, Frozen> | the store bound to one context and one instant |
toggle(key, enabled, context?) | FrozenWhenObserved<Frozen, ToggleResult<keyof S & FeatureKey>> | the only writer; willDisable, mayDisable |
reload(config) | ReloadResult | installs a candidate document, or refuses it |
resolve, plan, variantOf, valueOf and toggle all default context.now
to new Date() at the call.
variantOf and valueOf take their types off Decisions<S>[K], so a reader of
either sees what the decision for that key carries. Over the store
Variants builds,
features.variantOf('checkout-cta', { targetingKey: 'user-1' }) answers
'control', and features.valueOf on the same key and context answers
undefined, because the control variant declares no value.
Both answer undefined for a feature that resolved off. On a key whose schema
entry is never the return type is undefined on its own.
To install a new document, use reload, which is the only operation that
writes to version and envelope. In contrast, a toggle leaves both members
unchanged because they identify the document served by a publisher, whereas a
toggle is a local write. If a candidate is refused, every member above remains
at its previous value. See Distribution.
How @evanion/feature handles store types
To store a feature set that arrives at runtime—such as a store parseFeatureConfig builds from a fetched document—you use the bare Features type. Because S defaults to Schema, the bare form names a store whose schema is determined at runtime.
This bare form is the standard for framework declarations. A Nest provider token holding a store built from a document, a member of Astro’s locals, and a React Router context each write features: Features | undefined.
If you build a store from a literal, it will not fit that slot. When you use createFeatures([...]), the compiler infers a schema; the variantOf method then answers the variant names that schema declares. Because of this specific inference, the compiler refuses the assignment to the bare form with TS2322 on the types variantOf returns. To fix this, a declaration holding a store built from a literal must name its schema: Features<Flags>.
Handling frozen states in @evanion/feature
The bare Features form leaves Frozen at boolean, which means resolve and plan on the slot answer the union of both freezes.
This affects how decisions are passed through your application:
- A loader carrying the decisions to a page returns
snapshot()as the bareDecisionSet, which holds either freeze. - A build step names its plan
FrozenWhenObserved<boolean, Plan>.
import { } from '@evanion/feature';
import type {
,
,
,
,
,
} from '@evanion/feature';
// The document a fetch answers, whose keys the compiler never saw.
const : = {
: '2026-10-08.1',
: [{ : 'new-checkout', : true }],
};
const = ();
// The slot a framework declaration names, with no type argument.
const : | undefined = .
? .
: ;
// A loader hands the page the set, which holds either freeze.
function (): | undefined {
return ?.();
}
// A build step names the plan with the freeze left open.
function (): <boolean, > | undefined {
return ?.();
}
()?.['new-checkout']?.; // -> true
()?.['new-checkout']?.; // -> truefeatures.for(context?)
To ensure a series of feature checks are evaluated against the same moment in time and the same user data, you can bind the store to a specific context. The features.for(context?) function performs this by returning a BoundFeatures<S, Frozen>.
This handle reads context.now ?? new Date() exactly once at the moment of the call; every subsequent method called on that handle resolves using that same instant. This prevents a request from straddling a window boundary. For example, without a handle, calling isEnabled at 23:59:59 and again one second later could yield different answers for a now rule set for midnight. By using a handle, the result remains consistent for the duration of the request.
If the context provides an explicit now value, it takes precedence. This allows tests to pin the clock to a specific moment.
Because a toggle writes intent for every caller rather than asking a question about a specific context, the handle carries no toggle method; callers must toggle directly on the store.
A common use case for this pattern is in a NestJS service, an Express handler, or an Astro middleware. These environments can build one handle per request to ask multiple questions without threading the context through every function call or re-reading the clock.
One context’s answers, with the instant settled once.
Full documentation
features.for(context) returns it. Every method asks the store the question
the same-named member of Features asks, with the context and the instant
already supplied, so a service deep in a call graph holds this and names no
context at its call sites. @evanion/acl’s authorize(subject) binds a
subject the same way at libs/acl/src/hydrate-policy.ts:275.
It carries no toggle, because a toggle writes intent for every caller and
asks nothing about one context.
It memoises nothing. A toggle or a reload between two calls on one handle
is visible through the second, because the store’s truth is the store’s and a
handle that cached decisions would answer from a document the store no longer
holds.
BoundFeatures<S, Frozen> represents the answers for a single context with the evaluation instant settled. It is the return value of features.for(context).
The handle provides the following members:
context: The context every method resolves against, preserving the caller’s own fields and the settlednowvalue.resolve()isEnabled(key)variantOf(key)valueOf(key)plan()snapshot(options?)
Each method asks the store the same question as the corresponding member of Features, but with the context and instant already supplied. The return types match those of the store; for instance, a store built with an observer will return the deeply readonly form from resolve and plan.
The settled instant is available as an ISO 8601 string via snapshot().now. If context.now is an invalid Date, it is kept as given. In this case, resolve() will still answer, but snapshot() will raise the FeatureConfigError naming context/now (the same error raised by features.snapshot).
The handle does not memoise decisions. If a toggle or reload occurs on the store between two calls on one handle, the second call will reflect that change. This ensures the handle always answers based on the truth held by the store, rather than a stale document.
This binding pattern is mirrored in @evanion/acl, where authorize(subject) binds a subject in the same way.
The example shows the store being built once, with a handle built per request from a shopper to ask three questions at one single instant.
interface < extends <keyof , VariantInfo | never> = , extends boolean = boolean> {
readonly : EvaluationContext;
(): <, <>>;
(: keyof & ): boolean;
< extends keyof >(: ): <>[] extends {
?: infer ;
} ? | undefined : undefined;
< extends keyof >(: ): <>[] extends {
?: infer ;
} ? | undefined : undefined;
(): <, <>>;
(?: SnapshotOptions): <, >;
}
// Built per request, from the shopper the session names. `for` reads the
// clock here, once.
const = .({ : ., : . });
.('new-checkout'); // -> true
.('express-pickup'); // -> true
.('checkout-cta'); // -> 'blue'
..; // -> 'cust-0001'Core exports
import {
createFeatures,
bucketOf,
inRollout,
murmur3,
assignVariant,
variantSeedOf,
evaluateCondition,
canonical,
ruleId,
DEFAULT_ROLLOUT_FIELD,
FeatureConfigError,
FeatureCycleError,
UnknownDependencyError,
DuplicateFeatureError,
} from '@evanion/feature';| Export | What it is |
|---|---|
bucketOf(value, seed) | the bucket in [0, 1), for snapshot tooling and tests |
inRollout(value, percent, seed) | whether that bucket is inside the rollout |
murmur3(input, seed?) | the hash itself, unsigned 32-bit |
assignVariant(definition, context) | the variant a context gets, for snapshot tooling and tests |
variantSeedOf(definition) | the seed assignVariant buckets on, for the same reason |
evaluateCondition(cond, context) | one condition, for the same reason |
canonical(value) | text for a value with its keys sorted, for a hash two producers must agree on |
ruleId(rule) | the id resolve gives a rule that declares none |
DEFAULT_ROLLOUT_FIELD | 'targetingKey' |
The bucket value falls in for a rollout seeded with seed, in [0, 1).
Full documentation
2^32 buckets, and independent of any percentage, which is what makes raising one purely additive.
To determine which bucket a subject falls into during a rollout, @evanion/feature uses bucketOf. This function calculates a value in [0, 1) for a rollout seeded with seed. Because the bucket is a pure hash of the pair, it remains consistent across different evaluations, processes, and hosts.
The system provides 2^32 buckets. Since the bucket value does not depend on a percentage, raising a rollout is purely additive; a member who is already in a rollout will never move out.
Snapshot tooling and a test over one rollout call it. A caller passes the seed the rule carries, which resolve reads off rollout.seed, then the feature’s seed, then the key.
function (: string, : string): number;
What the tests state
5 behaviours
bucketOf
returns a value in [0, 1)
Reading the case from the package.
Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.
import { , } from '@evanion/feature';
const = ['cust-0007', 'cust-0042', 'cust-0101', 'cust-0113'];
.(() => (, 10, 'new-checkout')); // -> ['cust-0101', 'cust-0113']
('cust-0113', 'new-checkout'); // -> 0.03226795047521591Whether value is inside a percent rollout seeded with seed.
Full documentation
Strictly below the threshold, so 0% includes nobody; every bucket is below 1, so 100% includes everybody.
To check if a specific value is included in a rollout, @evanion/feature provides inRollout. This function answers whether a value is inside a percent rollout seeded with seed. It compares the bucket strictly below the threshold, meaning 0% includes nobody, and since every bucket is below 1, 100% includes everybody.
function (: string, : number, : string): boolean;
What the tests state
7 behaviours
inRollout
is decorrelated across features
Reading the case from the package.
Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.
import { , } from '@evanion/feature';
const = ['cust-0007', 'cust-0042', 'cust-0101', 'cust-0113'];
.(() => (, 10, 'new-checkout')); // -> ['cust-0101', 'cust-0113']
('cust-0113', 'new-checkout'); // -> 0.03226795047521591MurmurHash3, x86 32-bit, over the UTF-8 bytes of input.
To ensure consistent hashing, @evanion/feature implements murmur3. This is MurmurHash3, x86 32-bit, operating over the UTF-8 bytes of the input to return an unsigned 32-bit integer. Both bucketOf and configDigest rely on it, meaning any port to Swift or Kotlin must produce the same numbers.
The package walks the UTF-8 bytes itself, asking its host only for arithmetic and string indexing. An unpaired surrogate encodes as U+FFFD, the same result produced by the Encoding Standard’s encoder, ensuring the bytes agree with TextEncoder for every possible input.
function (: string, ?: number): number;
What the tests state
8 behaviours
murmur3
hashes with a real MurmurHash3, x86 32-bit
Reading the case from the package.
Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.
import { , , } from '@evanion/feature';
import type { } from '@evanion/feature';
const : = {
: 'checkout-cta',
: true,
: [
{ : 'control', : 50 },
{ : 'blue', : 50 },
],
};
(); // -> 'checkout-cta:variant'
(, { : 'user-1' }); // -> { variant: { name: 'control', weight: 50 }, source: 'weighted', by: 'targetingKey', bucket: 0.17582221026532352 }
(, { : 'user-2' }); // -> { variant: { name: 'blue', weight: 50 }, source: 'weighted', by: 'targetingKey', bucket: 0.7602026120293885 }
// The context carries no `targetingKey`, so the control answers and the
// assignment reports no bucket.
(, {}); // -> { variant: { name: 'control', weight: 50 }, source: 'fallback', by: 'targetingKey' }
('checkout-cta:variant'); // -> 70980636
('checkout-cta:variant', 1); // -> 2365933763The variant a context gets, for a feature that resolved on.
Full documentation
Three answers in order. A prior assignment the application stored wins, when it names a variant this feature still declares. The weights answer next, for a context carrying a usable bucketing value. The control answers last.
The control is the honest answer for an incomplete context. The feature
resolved on, so calling code needs a variant to render, and the control
admits nobody to the experiment. evaluateRule refuses a rollout the same
way when the context carries no bucketing value, so neither ramps anybody in
on missing data.
A pin from a matching rule beats all three, and decide applies it, because
only decide knows which rule matched.
This definition must come from createFeatures, or have already passed
validateVariants on its own. None of the weight sets validateVariants
would have refused throws here, and the answer each one gives is
deterministic and wrong.
A non-finite weight poisons the total. assignWeighted sums every weight,
so one Infinity makes the total Infinity and that variant’s own band
Infinity / Infinity, which is NaN; a NaN weight reaches NaN
directly. ceiling never recovers from one, every comparison against it is
false, and the loop falls out and returns the last-ordered variant for every
bucket, wherever in the set the bad weight sits.
A set weighted entirely zero divides 0 by 0 for every band and reaches
that same fall-out and that same last-ordered answer.
Finite weights overflow the total on their own. Two weights of
Number.MAX_VALUE sum to Infinity, which divides every band down to 0
rather than to NaN; no ceiling ever passes a bucket, and the loop falls
out to the last-ordered variant from there. variantErrors reads the total
and not the weights for this one, so its message names the overflow.
A negative weight shrinks the total rather than poisoning it, so some band’s
ceiling passes 1 and swallows every bucket below it. Which band that is
moves with the negative weight’s position: [-10, 50] hands every subject
the second variant, [50, -10] hands every subject the first, and
[50, -10, 20] splits them between the first and the third.
A negative weight that cancels the positives exactly sums to zero without
the set being all zeros, and the first band in bucketing order then divides
by zero. That first weight alone decides the answer, whatever sits behind
it. A positive weight there gives Infinity and takes every bucket, so
[10, -10] hands every subject the first variant. A negative weight gives
-Infinity and the next addition makes the ceiling NaN; a zero weight
divides 0 by 0 and makes it NaN at the first band. No comparison
against either one passes, so both reach the fall-out and the last-ordered
answer an all-zero set gives: [-10, 10] and [0, 10, -10] each hand
every subject the last variant.
Two variants sharing an order never throw either; bucketingOrder sorts
on the position and nothing separates a tie, so the first one declared keeps
the lower band.
To determine which variant a user receives for a feature that has resolved on, @evanion/feature uses assignVariant. If a feature declares no variants, it returns undefined.
The function evaluates three possible answers in this order:
- Sticky assignments: A prior assignment stored on
stickyVariantswins if it names a variant the feature still declares. - Weighted assignments: If the context carries a usable bucketing value, the weights determine the answer.
- Control fallback: The control answers last. This is the honest answer for an incomplete context: the feature resolved on, so the calling code needs a variant to render, and the control admits nobody to the experiment.
Because only decide knows which rule matched, a pin from a matching rule beats all three of these options, and decide applies it accordingly.
The definition passed to this function must come from createFeatures or have passed validateVariants independently. If a weight is non-finite, negative, or sums to zero across the set, or if two variants share an order, the function does not throw; instead, the outcome is deterministic and wrong.
function < extends >(: <>, : EvaluationContext): VariantAssignment | undefined;
What the tests state
28 behaviours
assignVariant
a definition validateVariants would have refused
returns nothing for a feature declaring no variants
Reading the case from the package.
Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.
import { , , } from '@evanion/feature';
import type { } from '@evanion/feature';
const : = {
: 'checkout-cta',
: true,
: [
{ : 'control', : 50 },
{ : 'blue', : 50 },
],
};
(); // -> 'checkout-cta:variant'
(, { : 'user-1' }); // -> { variant: { name: 'control', weight: 50 }, source: 'weighted', by: 'targetingKey', bucket: 0.17582221026532352 }
(, { : 'user-2' }); // -> { variant: { name: 'blue', weight: 50 }, source: 'weighted', by: 'targetingKey', bucket: 0.7602026120293885 }
// The context carries no `targetingKey`, so the control answers and the
// assignment reports no bucket.
(, {}); // -> { variant: { name: 'control', weight: 50 }, source: 'fallback', by: 'targetingKey' }
('checkout-cta:variant'); // -> 70980636
('checkout-cta:variant', 1); // -> 2365933763The seed a feature’s variant assignment buckets on.
Full documentation
The default appends to whatever the rollout seeds on, so the two buckets are
independent. Hashing the same pair for both puts every member of a 20%
rollout in the lowest 20% of the variant space, so a 50/50 split hands all of
them the control and the experiment measures nothing. Unleash seeds a rollout
with 0 and a variant with 86028157, and PostHog salts one with "" and the
other with "variant", for this reason.
To establish the seed used for variant bucketing, @evanion/feature provides variantSeedOf. This function gives the seed that assignVariant buckets on. If a definition names a variantSeed, it gets that string unchanged; otherwise, it receives ${seed ?? key}:variant.
By default, the seed appends to whatever the rollout seeds on, ensuring the two buckets are independent. If the same pair were hashed for both, every member of a 20% rollout would fall into the lowest 20% of the variant space; consequently, a 50/50 split would give all of them the control, and the experiment would measure nothing.
function < extends >(: <>): string;
What the tests state
7 behaviours
variantSeedOf
defaults to the key with a variant suffix
Reading the case from the package.
Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.
import { , , } from '@evanion/feature';
import type { } from '@evanion/feature';
const : = {
: 'checkout-cta',
: true,
: [
{ : 'control', : 50 },
{ : 'blue', : 50 },
],
};
(); // -> 'checkout-cta:variant'
(, { : 'user-1' }); // -> { variant: { name: 'control', weight: 50 }, source: 'weighted', by: 'targetingKey', bucket: 0.17582221026532352 }
(, { : 'user-2' }); // -> { variant: { name: 'blue', weight: 50 }, source: 'weighted', by: 'targetingKey', bucket: 0.7602026120293885 }
// The context carries no `targetingKey`, so the control answers and the
// assignment reports no bucket.
(, {}); // -> { variant: { name: 'control', weight: 50 }, source: 'fallback', by: 'targetingKey' }
('checkout-cta:variant'); // -> 70980636
('checkout-cta:variant', 1); // -> 2365933763The default context field a rollout buckets on, and a variant after it.
DEFAULT_ROLLOUT_FIELD is 'targetingKey'. This is the context field @evanion/feature uses for bucketing when a rollout spec names no by field, and the field variant assignment buckets on when the definition names no variantBy.
export declare const = "targetingKey";Calls observer and discards whatever it raises or rejects with.
Full documentation
An observer that takes down a render is worse than the divergence it was reporting, so the raise stops here and the caller carries on with the decisions it already computed.
DivergenceObserver answers void, and the compiler accepts a function
answering anything where a void answer is expected, so a caller can
install async (report) => { await post(report); }. Nothing on this frame
catches that function’s rejection, and Node terminates a process over an
unhandled rejection, so a thenable answer gets a rejection handler that
discards the reason. createEmitter reads an observer’s answer the same way
and routes the failure to onObserveError.
docs/specs/2026-09-23-feature-observation-seam.md owns the handler that
gives a divergence observer the same route.
@evanion/react-feature’s provider calls it for every report its checks
produced. An application that adopts a decision set outside React calls it with
the observer it holds.
function < extends >(: <> | undefined, : <>): void;
What the tests state
21 behaviours
reportDivergence
hands the observer the report it was given
Reading the case from the package.
Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.
import { } from '@evanion/feature';
import type { } from '@evanion/feature';
const : = {
: 'unversioned',
: 'one of the two carries no config version',
};
const : [] = [];
(() => .(), );
.(() => .); // -> ['unversioned']
// No observer installed, so nothing is called.
(, );
// An observer that raises alters nothing the caller holds.
(() => {
throw new ('the analytics sink is down');
}, );
.; // -> 1The context fields a rule reads, sorted and deduplicated.
A pinned assignment is only reproducible where the local context carries every field its rule read, and this is the list that question is asked against.
function (: Rule): readonly string[];
What the tests state
12 behaviours
ruleFields
names every context field a rule reads, sorted and deduplicated
Reading the case from the package.
Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.
import { } from '@evanion/feature';
import type { Rule } from '@evanion/feature';
const : Rule = {
: 'weekend-members',
: [
{ : 'tier', : 'eq', : 'member' },
{
: 'now',
: 'day-of-week',
: 'Europe/Stockholm',
: ['sat', 'sun'],
},
],
: { : 25, : 'customerId' },
};
(); // -> ['customerId', 'now', 'tier']Whether a condition holds for a context.
throws {RangeError} the host’s own, for a day-of-week zone this runtime
formats no time zone by. createFeatures and validateConfig refuse such a
zone where the configuration is supplied, which is what keeps the raise out
of resolve; a condition a caller wrote by hand has passed neither.
Full documentation
A condition over a field the context does not carry never holds — including
the negative operators. ne on an absent field is not “true because it is not
equal”; it is unevaluable, and treating it as true would switch features on
for exactly the contexts that carry the least information.
A window and a day-of-week condition read context.now through
contextEpoch, so a context that crossed JSON and carries the ISO 8601
string in its place resolves the way the context it was built from did. A
now naming no instant leaves both unevaluable, which is false.
To determine if a specific rule requirement is met, @evanion/feature provides evaluateCondition.
Every entry point that resolves a decision reaches this function through the rule walk, which asks it for each condition in a rule’s when and stops at the first one that answers false. A caller who holds a condition and a context can call it to get the engine’s own answer, which is the mechanism used by a test over one rule and a snapshot tool.
If a condition is defined over a field the context does not carry, it never holds; for this reason, ne and not-in answer false on an absent field. If a day-of-week condition names a zone that this runtime cannot format, it raises the host’s RangeError. While validateConfig and createFeatures refuse such a zone when the configuration is supplied, a condition written by hand may have passed neither.
function (: , : EvaluationContext): boolean;
What the tests state
37 behaviours
window
day-of-week
attributes
evaluateCondition
holds strictly before the instant
Reading the case from the package.
Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.
import { } from '@evanion/feature';
import type {
AttributeCondition,
DayOfWeekCondition,
EvaluationContext,
WindowCondition,
} from '@evanion/feature';
const : AttributeCondition = {
: 'tier',
: 'in',
: ['member', 'founder'],
};
const : WindowCondition = {
: 'now',
: 'before',
: '2026-03-01T00:00:00Z',
};
const : DayOfWeekCondition = {
: 'now',
: 'day-of-week',
: 'Europe/Stockholm',
: ['wed'],
};
const : EvaluationContext = {
: 'founder',
: new ('2026-02-25T18:00:00Z'),
};
(, ); // -> true
(, ); // -> true
(, ); // -> true
// The zone decides the day. This instant is Wednesday in UTC and Thursday in
// Stockholm, which is the zone the condition names.
const : EvaluationContext = {
: 'founder',
: new ('2026-02-25T23:30:00Z'),
};
(, ); // -> falseCanonical text for a value, with object keys in sorted order.
Full documentation
Two documents differing only in key order or in whitespace state one configuration. A rule id derived from the text of one must agree with a rule id derived from the text of the other, and a producer in another language emits its keys in whatever order its serializer chose.
Array order is preserved. in: ['a', 'b'] and in: ['b', 'a'] test the same
membership, and sorting them here would claim a semantic equality this
function does not generally have. A document that reorders a list is a
document somebody edited.
undefined properties are dropped, so an absent key and a key written as
undefined agree. A JSON document carries no undefined, and a document
built in memory can.
Every input type gets its own branch, so the return type is string for
all of them, not only the ones JSON.stringify happens to serialize.
JSON.stringify returns undefined for undefined, a function or a
symbol, and throws for a bigint; a caller trusting the declared string
return type would fail past the type checker on any of the four.
A bigint is written as its digits with a trailing n, 10n for
BigInt(10). evaluateCondition compares with ===, and 10n === 10n is
true while 10n === 10 is false, so a config authoring 10n and one
authoring 10 must not canonicalize to the same text. JSON.stringify
never emits a bare number followed by n, so the tag cannot collide with
any number this function writes.
NaN, Infinity and -Infinity are written behind a number: tag,
number:NaN for NaN, for the reason the bigint above carries its n.
JSON.stringify writes all three as null, and evaluateCondition compares
an AttributeCondition.value with === at conditions.ts:90: a rule holding
eq: NaN matches no context, one holding eq: Infinity matches a context
carrying that number, and one holding eq: null matches a null attribute.
ruleId derives a rule’s id from this text, and three rules that match three
audiences need three ids.
The value reaches here off a live store, which holds the number an author
wrote: structuredClone in createFeatures copies a non-finite number
unchanged. JSON.stringify never emits a bare number:, so the tag cannot
collide with any text this function writes.
A document can hold two of the three. serializeConfig refuses a non-finite
number at every member it emits and names the path, and JSON’s number grammar
accepts any exponent, so JSON.parse('{\"maxStale\":1e999}') hands a holder
Infinity and -1e999 hands it -Infinity. Only NaN is unreachable from
JSON text. A digest is defined over the document, and canonicalDocument is
what configDigest takes its text through for that reason.
A function or a symbol is written from its own toString(). Neither
carries a content-addressable value the way a plain object does — two
functions with the same source at different addresses are, for this
purpose, the same text, which is a reasonable identity for a condition
value nobody expects to compare structurally in the first place.
A value two paths reach is written once and the second path reads the memo,
the way serialized at serialize.ts:134 memoizes the same graph. Both
walks meet the sharing that structuredClone in createFeatures and
deepFreeze preserve, and a diamond 22 levels deep holds 23 objects and
2^22 paths to its leaf: the memo walks 23 of them and costs 34ms where the
bare recursion costs 2.8s.
The text the sharing expands to is the same either way, 88MB for that
diamond, because JSON carries no sharing. A back-reference form would be
shorter and would disagree with the digest a holder computes over the
document JSON handed it, which is the comparison § 2 of
docs/specs/2026-09-23-feature-config-distribution.md rests on. At 25
levels this and JSON.stringify both throw RangeError: Invalid string length, so a document too deep to digest is a document too deep to serve.
To ensure that two logically identical configurations produce the same hash regardless of how they were serialized, you need a stable text representation. The canonical function solves this by writing one text for a value, ensuring every object’s keys are written in sorted order. This allows a producer in another language to emit keys in any order and still agree with a hash taken over the same configuration.
The tool maintains a strict boundary between object keys and array elements: array order survives. Because in: ['a', 'b'] and in: ['b', 'a'] test the same membership, sorting a list would claim an equality the function does not generally possess, so canonical writes two different texts for them. A document that reorders a list is treated as a document somebody edited.
To handle JavaScript’s type idiosyncrasies, @evanion/feature applies the following rules:
- An
undefinedproperty is dropped, so an absent key and a key written asundefinedagree. - A
Dateis written as its ISO 8601 string. - A
bigintis written as its digits with a trailingn(e.g.,10nforBigInt(10)). NaN,Infinity, and-Infinityare written behind anumber:tag (e.g.,number:NaNforNaN).- Functions and symbols are written using their own
toString().
These distinctions are necessary because JSON.stringify loses precision—it returns undefined for functions and symbols, throws for bigint, and writes null for all three non-finite numbers. Since evaluateCondition compares an attribute condition’s value with ===, a rule holding eq: NaN must match no context, whereas one holding eq: Infinity matches a context carrying that number. ruleId derives a rule’s id from this text, meaning those three rules require three distinct ids.
Note that configDigest does not read this text. It performs a walk that writes non-finite numbers as null. This prevents a discrepancy where one holder parses 1e999 as Infinity and another, who cached the document via JSON.stringify, holds it as null.
To optimize performance, a value that two paths reach is written once and the second path reads a memo. In a diamond 22 levels deep containing 23 objects and 2^22 paths to its leaf, the memo walks 23 of them in 34ms, whereas bare recursion costs 2.8s. The resulting text remains the same—88MB for that diamond—because JSON carries no sharing. At 25 levels, both this function and JSON.stringify throw RangeError: Invalid string length; a document too deep to digest is a document too deep to serve.
function (: unknown): string;
What the tests state
20 behaviours
canonical
sorts object keys
Reading the case from the package.
Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.
import { , , } from '@evanion/feature';
import type { , Rule } from '@evanion/feature';
// Two objects whose keys were written in two orders carry one text.
({ : 'gold', : 'eu' }); // -> '{"region":"eu","tier":"gold"}'
({ : 'eu', : 'gold' }); // -> '{"region":"eu","tier":"gold"}'
// A non-finite number carries a `number:` tag. A `Date` holding no instant is
// written `null`, the text a JSON-borne `null` carries.
(.); // -> 'number:NaN'
(new ('nonsense')); // -> 'null'
const : Rule = {
: 'staff-only',
: [{ : 'staff', : 'eq', : true }],
};
const : Rule = {
: [{ : 'tier', : 'eq', : 'gold' }],
};
(); // -> 'staff-only'
(); // -> 'rule-2d092ba6'
// A number and a `Date` that name no instant are both read as `NaN`, so one
// condition shape derives one id for either.
const : Rule = {
: [{ : 'now', : 'after', : . }],
};
const : Rule = {
: [{ : 'now', : 'after', : new ('nonsense') }],
};
(); // -> 'rule-a2b37cba'
(); // -> 'rule-a2b37cba'
// The operator is hashed alongside the boundary, so the same pair of
// boundaries under `op: 'before'` derives another id.
const : Rule = {
: [{ : 'now', : 'before', : . }],
};
const : Rule = {
: [{ : 'now', : 'before', : new ('nonsense') }],
};
(); // -> 'rule-d7804a19'
(); // -> 'rule-d7804a19'
// The `NaN` rule and the unreadable-`Date` rule on one feature answer one id,
// which `validateConfig` reports alongside the defect on each boundary.
const = {
: 7,
: [
{
: 'new-checkout',
: false,
: [, ],
},
],
} as unknown as ;
const = ();
const = . ? [] : ..(() => .);
.(() => === 'invalid-instant').; // -> 2
.('duplicate-rule-id'); // -> trueThe id a rule carries into a Decision. An explicit rule.id wins.
Full documentation
The fallback is derived from what the rule matches on, so it survives a rule
being inserted above it, the list being reordered, a ramp being moved, and
the same document being emitted by a producer in another language. A
positional #0 holds through none of those, and a log line holding one
means something different once the list moves on.
A control plane that assigns an id at rule creation writes it as rule.id
and this derivation never runs. See
docs/specs/2026-09-23-feature-config-distribution.md decision 5.
An author who wants a name in a log writes id: 'staff-only', which reads
better than a hash and is what the docs recommend.
To identify a rule within a Decision, you use ruleId. This function determines the id a rule carries, prioritizing an explicit rule.id if it exists. If a control plane assigns an id at rule creation, that value wins and the derivation never runs; similarly, an author can write id: 'staff-only' to ensure a specific name appears in logs.
When an id must be derived, ruleId produces a string starting with rule- followed by eight hex digits of FNV-1a over the text of what the rule matches on: each condition, and the rollout’s by and seed. This derivation is stable even if a rule is inserted above it, the list is reordered, a ramp is moved, or the document is emitted by a producer in another language. This is superior to a positional #0, which changes as soon as the list moves.
The derivation specifically excludes rollout.percent. Because by and seed decide which subjects a rule can reach and percent decides how many of them it reaches now, an operator moving the percentage is running the same rule harder. Including the percentage in the hash would rename the rule on every ramp, causing an event stream from before the ramp to join to nothing after it. Consequently, two rollout rules on one feature that share conditions, by, and seed will derive the same id unless the author separates them with an explicit rule.id.
When calculating a rule’s identity, boundaries are processed to eliminate ambiguity between different representations of the same moment in time. A window condition’s boundary is hashed as epoch milliseconds, so one instant written three ways reads as one value.
For a boundary that names no valid instant—a string, or a value Instant does not admit that a document parsed out of JSON carried in, such as null, an object, a boolean, or an array—the package treats the value as literal text and hashes it as its own text. Consequently, two hosts reading the rule against their own zones still derive one id, and two rules carrying different unusable boundaries keep different ids.
To handle values that do not represent a valid point in time, @evanion/feature provides exceptions for Number.NaN and for a Date holding no instant:
- Both are hashed as the text
NaN, meaning one condition shape derives one id for either. For example,ruleIdover a rule whose only condition is{ field: 'now', op: 'after', value: Number.NaN }and over the same condition carryingnew Date('nonsense')derivesrule-a2b37cbaeither way. Because the operator is hashed alongside the boundary, the same pair of boundaries underop: 'before'derivesrule-d7804a19instead. Where theNumber.NaNrule and thenew Date('nonsense')rule sit on one feature,validateConfigreportsduplicate-rule-idfor the pair on top of theinvalid-instantit reports for each boundary. - Every other number is hashed as its own decimal digits, a number past the range a
Dateholds included.8640000000000001and9e15are two boundariescreateFeaturesrefuses for naming no instant, and the two rules carrying them keep two ids. - To avoid conflicts, the derivation reads the epoch instead of the text
canonicalwrites; this is becausecanonicalwritesnumber:NaNfor the number andnullfor theDate. Since thatnullis the text a JSON-bornenullcarries, a rule holding an unreadableDateand a rule holdingnullwould derive one id. - Because
createFeaturesrefuses every string, number andDatethat names no instant, this path is what a caller reachingruleIddirectly gets.
To prevent collisions where a field name abuts an operator, every variable-length part of the text, including op, carries its length. This ensures that predicates with similar character sequences but different boundaries are written as different texts. For example, { field: 'usernot-', op: 'in' } and { field: 'user', op: 'not-in' } are opposite predicates and are hashed uniquely.
The use of FNV-1a is a performance choice rather than a security one. Evaluation is synchronous, whereas Web Crypto’s digest returns a promise, and no security property rests on the difficulty of finding a second rule that hashes the same. The hash walks UTF-16 code units to maintain the cross-language contract that implementations in other languages match.
function (: Rule): string;
What the tests state
39 behaviours
ruleId
returns an explicit id unchanged
Reading the case from the package.
Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.
import { , , } from '@evanion/feature';
import type { , Rule } from '@evanion/feature';
// Two objects whose keys were written in two orders carry one text.
({ : 'gold', : 'eu' }); // -> '{"region":"eu","tier":"gold"}'
({ : 'eu', : 'gold' }); // -> '{"region":"eu","tier":"gold"}'
// A non-finite number carries a `number:` tag. A `Date` holding no instant is
// written `null`, the text a JSON-borne `null` carries.
(.); // -> 'number:NaN'
(new ('nonsense')); // -> 'null'
const : Rule = {
: 'staff-only',
: [{ : 'staff', : 'eq', : true }],
};
const : Rule = {
: [{ : 'tier', : 'eq', : 'gold' }],
};
(); // -> 'staff-only'
(); // -> 'rule-2d092ba6'
// A number and a `Date` that name no instant are both read as `NaN`, so one
// condition shape derives one id for either.
const : Rule = {
: [{ : 'now', : 'after', : . }],
};
const : Rule = {
: [{ : 'now', : 'after', : new ('nonsense') }],
};
(); // -> 'rule-a2b37cba'
(); // -> 'rule-a2b37cba'
// The operator is hashed alongside the boundary, so the same pair of
// boundaries under `op: 'before'` derives another id.
const : Rule = {
: [{ : 'now', : 'before', : . }],
};
const : Rule = {
: [{ : 'now', : 'before', : new ('nonsense') }],
};
(); // -> 'rule-d7804a19'
(); // -> 'rule-d7804a19'
// The `NaN` rule and the unreadable-`Date` rule on one feature answer one id,
// which `validateConfig` reports alongside the defect on each boundary.
const = {
: 7,
: [
{
: 'new-checkout',
: false,
: [, ],
},
],
} as unknown as ;
const = ();
const = . ? [] : ..(() => .);
.(() => === 'invalid-instant').; // -> 2
.('duplicate-rule-id'); // -> trueserializeConfig(features, envelope?)
To write a @evanion/feature store out as one document, you use serializeConfig(). This function captures every definition, every rule with the id its author or its control plane gave it, and every variant with its name, weight, order and value.
function serializeConfig<S extends Record<keyof S, VariantInfo | never>>(
features: Features<S, boolean>,
envelope?: ConfigEnvelope,
): FeatureConfig<keyof S & FeatureKey>;The envelope defaults to features.envelope.
import { , } from '@evanion/feature';
const = ([
{ : 'new-checkout', : true },
{ : 'express-pickup', : true, : ['new-checkout'] },
]);
const = (, {
: '2026-10-08.1',
: 60_000,
});
.; // -> '2026-10-08.1'
.; // -> 60000
..; // -> 2
.; // -> undefinedThis call takes no mode and it checks nothing. Because configDigest is the one writer of the digest member, ConfigEnvelope fences digest to never.
The function handles dates and invalid types strictly. A Date at a window condition’s value becomes its ISO 8601 string, but a Date at any other member throws FeatureConfigError naming the path. A value that holds itself, a Map, a Set, an Error and a non-finite maxStale throw the same way.
See Distribution for what a document carries.
configDigest(config)
To identify a specific configuration state, configDigest() returns a 32-character hex string over the text of a @evanion/feature document.
function configDigest(config: FeatureConfig): string;Before the text is taken, the function sorts the keys, drops undefined properties, and removes the digest and version members. Four fixed murmur3 seeds widen the 32-bit hash to a 128-bit digest.
import {
,
,
,
} from '@evanion/feature';
const = ([{ : 'new-checkout', : true }]);
const = (, { : 41 });
const = ();
.; // -> 32
// `version` is outside the text, so a relabelled document digests the same.
({ ..., : 42 }) === ; // -> true
// Every other member is inside it.
({ ..., : 60_000 }) === ; // -> falseThis ensures that two documents differing only in key order or whitespace state one configuration and digest the same. Because the digest survives a JSON hop, two processes that computed one digest hold one configuration. It is not a cryptographic digest.
validateConfig(config)
To check for errors in a candidate @evanion/feature document without throwing exceptions, you use validateConfig().
function validateConfig(config: FeatureConfig): ValidationResult;This is the single checker behind createFeatures, parseFeatureConfig and features.reload, and it trusts nothing about its argument.
import { } from '@evanion/feature';
import type { } from '@evanion/feature';
const : = {
: 42,
: [
{ : 'new-checkout', : true },
{ : 'new-checkout', : true },
{ : 'express-pickup', : true, : ['nowhere'] },
],
};
const = ();
.; // -> false
. ? [] : ..(() => .); // -> ['duplicate-feature', 'unknown-dependency']
. ? [] : ..(() => .); // -> ['new-checkout', 'express-pickup']Each ConfigIssue reports a code, a message, the key the issue is about (where applicable), and a JSON pointer in path for an issue that names a member; a duplicate-feature or unknown-dependency issue names a key, a cycle issue names none, and no graph issue names a member. The codes are closed at 18:
| Group | Codes |
|---|---|
| Graph | duplicate-feature, unknown-dependency, cycle |
| Variants | duplicate-variant, unknown-variant, invalid-weight, empty-variants, zero-weights, duplicate-variant-order, invalid-variant-order |
| Rules | duplicate-rule-id, invalid-instant, unknown-context-field, field-type-mismatch |
| Envelope | unfenced-schema, missing-schema-version, unknown-member, digest-mismatch |
parseFeatureConfig(config, options?)
To build a @evanion/feature store from a document while reporting what was refused, you use parseFeatureConfig().
function parseFeatureConfig<
S extends Record<keyof S, VariantInfo | never> = Record<
FeatureKey,
VariantInfo | never
>,
>(
config: FeatureConfig<Extract<keyof S, FeatureKey>>,
options?: FeatureOptions<S>,
):
| { ok: true; features: Features<S, boolean> }
| { ok: false; issues: readonly ConfigIssue[] };This function differs from createFeatures over the same document in one way: it throws nothing.
import { } from '@evanion/feature';
import type { } from '@evanion/feature';
const : = {
: '2026-10-08.1',
: [
{ : 'new-checkout', : true },
{ : 'express-pickup', : true, : ['new-checkout'] },
],
};
const = ();
.; // -> true
. && ..; // -> '2026-10-08.1'
. && ..('express-pickup'); // -> trueBecause a document that arrived as JSON carries no literal, the compiler cannot infer variant names from it. Consequently, the caller must name the schema, such as parseFeatureConfig<ShopFlags>(document). The options are the same FeatureOptions createFeatures takes, including observe and correlateBy.
Types
Stored intent for one feature. This is configuration: evaluation never writes to it.
To define the features available to your application, you use a FeatureDefinition. The array of these is what createFeatures takes. A definitions literal gives the store its key union and its variant names, because key and variants carry the type parameters InferSchema reads. See Configuration.
To transmit a feature definition over the wire, @evanion/feature uses the SerializedDefinition type. In this wire form, rules and variants hold the serialized forms of a rule and of a variant.
interface < extends = string> {
: ;
: boolean;
?: readonly [];
?: readonly Rule[];
?: string;
?: boolean;
?: readonly VariantSpec[];
?: string;
?: string;
}
The configuration createFeatures takes: one definition per feature, keyed on
K.
Full documentation
Name this type in a satisfies clause, not in an annotation. const definitions = [...] as const satisfies Definitions<K> checks the literal
against this type and leaves the variable the literal’s own type, so the
inferring overload reads the variant names off it. The annotation const definitions: Definitions<K> = [...] gives the variable this type itself, and
this type declares variants optionally, so InferSchema reads no names off
it and variantOf widens to string | undefined. A caller who wants the
union back either moves to satisfies or names a schema, which the second
overload narrows for them.
To define the features available in your application, you provide a set of definitions to @evanion/feature. The configuration function createFeatures takes one FeatureDefinition per feature, keyed on K.
You should name this type in a satisfies clause rather than using a type annotation. For example, const definitions = [...] as const satisfies Definitions<Flag> checks the literal against the type while preserving the literal’s own type. This allows the inferring overload to read the variant names directly from the literal. The createFeatures entry states this rule and explains what an annotation costs a caller.
export type < extends = > = readonly <>[];What createFeatures accepts: a bare array, or the document an envelope
carries.
Full documentation
A literal has no version, no schema and no freshness claim, and an author writing four flags in a file owes none of them. A process whose poller already fetched a document hands that document over without unwrapping it.
When calling createFeatures in @evanion/feature, you can provide either a bare definitions array or a complete document carried by an envelope. The type DefinitionsOrConfig is the union that represents these two shapes; the createFeatures entry covers both call patterns.
A literal definition has no version, schema, or freshness claim, meaning an author writing four flags in a file owes none of them. Conversely, a process whose poller has already fetched a document can hand that document over without unwrapping it.
export type < extends = > = readonly <>[] | <>;The definitions inside whichever form a caller passed.
Full documentation
InferSchema reads variant names off a definitions array and constrains its
own parameter to one, so InferSchema<D> stops compiling the moment D may
be a document: D extends DefinitionsOrConfig fails that constraint. This
recovers the array, and every place the current signatures write
InferSchema<D> writes InferSchema<DefinitionsOf<D>> instead.
To extract the actual definitions regardless of the input format, @evanion/feature uses DefinitionsOf. This type resolves the union declared by DefinitionsOrConfig: DefinitionsOf<D> returns D if D is a definitions array, the features member if D is a document, and never for any other input.
This distinction is necessary because InferSchema reads variant names off a definitions array and constrains its own parameter to one. Consequently, InferSchema<D> would stop compiling if D might be a document, as D extends DefinitionsOrConfig fails that constraint. To solve this, every schema position in the createFeatures signature writes InferSchema<DefinitionsOf<D>> instead of InferSchema<D>.
export type <> = extends readonly <>[] ? : extends {
: infer extends readonly <>[];
} ? : never;The schema a definitions array implies.
Full documentation
A definition that declares no variants maps to never, and Decisions and
Plan read that never as “this feature has no variants”.
Three cases, and the middle one is why the 'variants' extends keyof E test
is here. A member whose variants property is required and literal supplies
its names. A member with no variants key at all declares no variants, and
never records that. A member typed FeatureDefinition<K>, which every
element of a plain array type is, carries a variants key the compiler cannot
read names off, because FeatureDefinition declares the property optionally.
That member maps to VariantInfo, so variantOf answers string | undefined
for it. never would type that same reader as undefined while the store
hands back a real variant name.
To determine the schema implied by a definitions array in @evanion/feature, you use InferSchema. It processes each member of the array, keys the result on that member’s key, and extracts the variant names and value types from the variants property.
The resulting schema depends on how the member is defined:
- Literal variants: If a member’s
variantsproperty is required and a literal supplies its names, the entry carries the union of those names atvariantand the union of the declared value types atvalue. A variant that declares novaluecontributes nothing to that union, so a feature whose variants are split between declaring a value and not declaring one carries only the declared ones atvalue. - Missing variants: If a member has no
variantskey at all, it declares no variants and the entry isnever.DecisionsandPlaninterpret thatneverto mean “this feature has no variants”. - Generic definitions: If a member is typed as
FeatureDefinition, it carries avariantskey that the compiler cannot read names from becauseFeatureDefinitiondeclares the property as optional. In this case, the entry isVariantInfo, andvariantOfreturnsstring | undefined. This occurs for every element of a plain array type.
Because an array held in a variable annotated Definitions<Flag> is a plain array type, every one of its members falls into this third case; this is why as const is required.
export type < extends readonly <>[]> = {
[ in [number] as ['key']]: extends {
: infer extends readonly VariantSpec[];
} ? {
: [number]['name'];
: <[number]>;
} : 'variants' extends keyof ? VariantInfo : never;
};A schema in the form a generic accepts.
Full documentation
Features constrains its parameter self-referentially. The compiler cannot
prove that InferSchema<D> satisfies that constraint before a call resolves:
InferSchema remaps its keys, and D is a bare type parameter. This copy
tests every entry against VariantInfo, which is the test the constraint
applies, and the constraint then holds. Each entry keeps the type it had,
including the never that a feature declaring no variants maps to.
To ensure a schema is in a form that a generic can accept, @evanion/feature uses AsSchema<T>. This maps every entry of T through T[K] extends VariantInfo ? T[K] : never, which is the specific test applied by the constraint S extends Record<keyof S, VariantInfo | never>.
The Features type constrains its schema parameter this way, but the compiler cannot prove that InferSchema<D> satisfies this constraint before a call resolves because InferSchema remaps keys and D is a bare type parameter. AsSchema applies the test so the constraint holds, which is why the first signature of createFeatures writes AsSchema<InferSchema<DefinitionsOf<D>>> at every schema position. Each entry retains its type, including the never assigned to features with no variants. A caller never writes this type manually.
export type <> = {
[ in keyof ]: [] extends VariantInfo ? [] : never;
};A map from feature key to what that feature’s variants are.
Full documentation
A consumer writes one of these to type a store built from configuration that
arrived as JSON, where there are no literals for InferSchema to read. A
store built from a literal gets its schema inferred and nobody writes this.
This alias annotates a value. A generic that takes a schema constrains it
self-referentially, S extends Record<keyof S, VariantInfo | never>, the way
Decisions and Plan do: the index signature this alias carries rejects the
interface a consumer writes by hand.
A Schema in @evanion/feature is a map from a feature key to its associated variants. You use this to type a store built from configuration that arrives as JSON, where no literal exists for InferSchema to read. If a store is built from a literal, the schema is inferred automatically. DecisionSet uses this as the default for its own schema parameter.
While this alias can annotate a value, generics that take a schema use a self-referential constraint instead: S extends Record<keyof S, VariantInfo | never>, as seen in Features, Decisions, and Plan. This is because the Schema alias carries an index signature. A hand-written interface ShopFlags { preorderBanner: { variant: 'wide' | 'tall' } } satisfies the constraint because keyof ShopFlags names its declared keys, but it does not satisfy the Schema alias. For example, the annotation const flags: Schema = handWritten reports TS2322: Type 'ShopFlags' is not assignable to type 'Schema' with Index signature for type 'string' is missing in type 'ShopFlags', whereas createFeatures<ShopFlags>(document) accepts that same interface.
This type represents the TypeScript side of a store’s typing. The other side is FeatureSchema, which types the schema member of a document read by validateConfig.
export type = <string, VariantInfo | never>;One activation rule. Matches when every when condition holds AND, if a
rollout is present, the bucketed value falls inside it.
To control when a feature resolves on, @evanion/feature uses a Rule. Rules are OR-ed: the feature resolves on when any one of them matches. A rule that declares neither when nor rollout matches every context, so an author narrowing anything writes at least one of the two. See Rollouts for rollout and Variants for variant.
To transmit a rule over the wire, @evanion/feature uses the SerializedRule type. This wire form ensures that conditions do not contain Date objects.
interface Rule {
?: string;
?: readonly [];
?: RolloutSpec;
?: string;
}
A percentage rollout.
Full documentation
by names the context field to bucket on, defaulting to targetingKey.
seed overrides the bucketing seed, which otherwise is the definition’s own
seed and the feature key when it names neither — the defaulting that
decorrelates a user’s bucket across features. Override it
only to deliberately correlate two features, e.g. to keep a pair of flags
rolling out to the same cohort.
To ramp a feature across a share of subjects, a rule in @evanion/feature carries a RolloutSpec.
percentdefines the share of subjects.bynames the context field to bucket on, defaulting totargetingKey.seedoverrides the bucketing seed.
Unless overridden, the seed is the feature’s own seed, and the feature key when the definition names neither, which decorrelates a subject’s bucket across different features. You should override it only to correlate two features deliberately, such as keeping a pair of flags rolling out to the same cohort. See Rollouts.
interface RolloutSpec {
: number;
?: string;
?: string;
}
One check a rule makes: a window over now, a day of week in a named zone,
or a context attribute compared against a literal. The union is closed
because evaluateCondition dispatches on op and validateConfig holds a
serialized rule to these three shapes, so a fourth member changes the wire
format and every consumer that walks a rule. A caller who needs a check of
their own puts the value on the context and writes an AttributeCondition
over it.
To specify a requirement within a rule, you use a Condition. A rule’s when is an array of conditions and every one of them has to hold. op discriminates the three members, and field is 'now' on the two that read the clock.
To transmit a condition over the wire, @evanion/feature uses the SerializedCondition type. This wire form narrows both the instant and the attribute value to types that JSON can hold.
export type = WindowCondition | DayOfWeekCondition | AttributeCondition;now is before / after a fixed instant.
To restrict a feature to a specific time frame, @evanion/feature provides the WindowCondition. value is an Instant, and createFeatures refuses a boundary that names no moment every host reads the same way.
To transmit a window condition over the wire, @evanion/feature uses the SerializedWindowCondition type. In this wire form, the value is represented as either an ISO 8601 string or epoch milliseconds.
interface WindowCondition {
: 'now';
: 'before' | 'after';
: ;
}
now falls on one of these weekdays, in an explicit IANA zone.
Full documentation
The zone is required, not defaulted: UTC day-of-week is wrong for every business rule anyone writes.
To target users based on the day of the week, you use the DayOfWeekCondition. The library hands zone to Intl, so the host’s zone database decides the day and its daylight-saving transitions. A condition that names no zone does not type-check.
interface DayOfWeekCondition {
: 'now';
: 'day-of-week';
: string;
: readonly [];
}
A context attribute compared against a literal.
To match a feature against user or system attributes, you use the AttributeCondition. field names a context member and value is the literal it is compared against. in and not-in read value as the array; contains reads the context member as the array.
To transmit an attribute condition over the wire, @evanion/feature uses the SerializedAttributeCondition type. This wire form narrows the value to a JsonValue.
interface AttributeCondition {
: string;
: 'eq' | 'ne' | 'in' | 'not-in' | 'contains';
: unknown;
}
Weekday names, in the IANA zone a day-of-week condition names.
To specify a day of the week in a condition, use the Weekday type. These are the seven lower-case short names, which is what DayOfWeekCondition.value holds.
export type = 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat';Evaluation context. now is injected rather than read from an ambient clock,
defaulting to new Date() at the call.
To provide the data necessary for a decision, @evanion/feature requires an EvaluationContext. Every call that resolves a decision takes one.
The context uses an index signature to admit any field a condition reads, while targetingKey is the field a rollout buckets on when the spec names none. The library reads stickyVariants and writes it nowhere; see Variants for where an application keeps it.
To control how the library evaluates time-based conditions, you provide a now property. This property accepts an Instant, which can be a Date object, the ISO 8601 string that JSON.stringify writes in a Date’s place, or epoch milliseconds.
This flexibility allows a context to maintain consistency across different environments. Because it supports these formats, a context that crossed an RSC payload, an island’s props, a response body, or a cookie resolves every window and day-of-week condition the same way the context it was serialized from did. Similarly, a caller handing on a parsed body passes it straight through.
If a now property names no instant, every clock condition becomes unevaluable and answers false. Note that snapshot refuses such a value outright, as a decision set states its instant specifically as an ISO 8601 string.
interface EvaluationContext {
?: ;
?: string;
?: <<string, string>>;
[: string]: unknown;
}
Why one rule did not match.
To understand why a rule did or did not match, you can inspect the RuleOutcome. failed names the first condition that failed. An outcome plan() settled drops it when the walk stepped over a condition the build-time context could not read, because a request carrying that field may fail the rule on that one instead. Build-time plans states the rule.
interface RuleOutcome {
: string;
: boolean;
?: ;
?: {
: number;
: string;
?: number;
: boolean;
};
}
One variant of a feature.
Full documentation
weight is relative. Weights are normalised across the set, so they need not
sum to 100, and an author raising one lowers every other share.
order fixes where this variant sits in the bucketing walk. The array index
supplies it when an author leaves it out, and a control plane writes it
explicitly so a serializer that reorders the array assigns identically.
To declare one variant of a feature in @evanion/feature, an author writes a VariantSpec.
weightis relative: weights are normalised across the set, so they do not need to sum to 100, and raising one lowers every other share.orderfixes where the variant sits in the bucketing walk. If an author leaves it out, the array index supplies the value.
A control plane writes order explicitly so that a serializer that reorders the array still assigns identically. See Variants.
To transmit a variant specification over the wire, @evanion/feature uses the SerializedVariantSpec type. This wire form keeps value as unknown and leaves the fence to serializeConfig.
interface VariantSpec {
: string;
: number;
?: number;
?: unknown;
}
How a subject reached its variant. Output only.
assignVariant returns a VariantAssignment. This object contains the chosen VariantSpec, the by field it bucketed on and, in the case of 'weighted', the bucket.
The source is 'weighted' | 'sticky' | 'fallback'. There is no 'pinned' case because only decide knows which rule matched, and decide applies a pin on top of the assignment assignVariant returned. A document author never writes one: decide reads the assignment into the decision it returns, and @evanion/react-feature reads it again when it compares a shipped decision against a local one. See Variants.
interface VariantAssignment {
: VariantSpec;
: 'weighted' | 'sticky' | 'fallback';
: string;
?: number;
}
What a schema records about one feature: its variant names and their values.
To track the specific state of a feature, @evanion/feature uses VariantInfo. This interface records the details of a single feature variant, consisting of a variant name typed string and an optional value typed unknown.
While VariantInfo defines the general structure of a record, the actual union of declared variant names and the value type associated with each name is determined by InferSchema reading a definitions literal.
In @evanion/feature, the Schema maps every feature key to a VariantInfo. Its declaration writes VariantInfo | never, and that union collapses so no key of a Schema maps to never. This never mapping belongs to InferSchema, with one exception: a member typed FeatureDefinition<K> maps to VariantInfo and not to never.
interface VariantInfo {
: string;
?: unknown;
}
One feature’s decision.
Full documentation
enabled is the decision. Everything else is explanation, and is output
only — nothing in this library reads reason, rule, rules, blockedBy,
cause, variant, value or assignment back to decide anything.
To understand why a specific feature is on or off, you look at a Decision. In @evanion/feature, the enabled member is the final decision, while every other member provides the supporting explanation.
The engine treats these explanation fields as output only; nothing in @evanion/feature consults reason, rule, rules, blockedBy, cause, variant, value, or assignment to determine enablement, as verified in reason-is-output-only.spec.ts.
V and T default to string and unknown, and F defaults to string. The @evanion/react-feature context publishes Decision<FeatureKey> and not a bare Decision: FeatureKey is string | number, so a store keyed on a numeric enum produces a Decision<7> whose key does not assign to a bare Decision’s. The widening holds at the constraint, which is why useFeature returns Decision<FeatureKey>.
When a variant is involved, assignment records how it was chosen:
source:'weighted' | 'pinned' | 'sticky' | 'fallback'by: The field the engine used for bucketing.bucket: The bucket value (absent if the context carried no bucketing value).rule: The name of the pinning rule (present only whensourceis'pinned').
See Variants for more detail.
interface < extends = string, extends string = string, = unknown> {
: ;
: boolean;
: ;
?: string;
?: readonly RuleOutcome[];
?: ;
?: <>;
?: ;
?: ;
?: {
: 'weighted' | 'pinned' | 'sticky' | 'fallback';
: string;
?: number;
?: string;
};
}
One decision per feature, each narrowed to what its own variants allow.
Full documentation
The constraint names keyof S. An index signature in that position rejects
the schema a consumer writes as an interface, which is the one case the
hand-written schema exists for.
To resolve a full set of features, @evanion/feature uses Decisions, which maps one decision per feature, each narrowed to the schema entry for its own key. Over the store Variants builds, a lookup like features.resolve()['checkout-cta'].variant results in 'control' | 'blue' | undefined.
If a feature declares no variants, its entry drops the variant and value fields but retains rule, rules, blockedBy, cause, and assignment as optional fields.
The signature names a helper, DecisionOf, which is internal to the package. It uses a [E] extends [never] guard to drop the variant keys. Because never is a subtype of every type, a plain E extends VariantInfo check would incorrectly take the variant branch for a feature with none and type its variant as never without reporting an error. The tuple wrap suppresses this distribution to ensure the branch asks the intended question.
S defaults to Schema, so the bare Decisions holds what resolve() answers on a store built with no observer, at any schema, and on a store typed Features<Schema, false>.
To handle the result of a store held in the bare Features slot, or built by parseFeatureConfig, you must account for how the Frozen state affects the return type. Because this configuration leaves Frozen at boolean, its resolve() method answers with Decisions | DeepReadonly<Decisions>.
The DeepReadonly<Decisions> member types the Date a failed window condition in a rule outcome can carry as a ReadonlyDate, which the mutable Decisions form refuses; consequently, the bare Decisions type does not accept that union, and the compiler reports TS2322. To resolve this when a loader carries decisions from such a store to a page, the loader returns snapshot() and names its return type DecisionSet, which holds either freeze. The example under Features<S> shows that loader.
export type < extends <keyof , VariantInfo | never> = > = {
[ in keyof ]: <, []>;
};The root cause of a cascade: the first ancestor off for a non-dependency reason.
When a feature is disabled because of a dependency cascade, you use Cause to find the root of the problem. Cause represents the first ancestor in the chain that resolved off for its own reason.
A decision with a reason of dependency-off carries the Cause alongside blockedBy (which names the immediate parent). This allows a UI to tell an operator which specific feature must be turned back on, while a graph view can draw the dependency edge.
If exactly one rule is responsible, either the matching rule or the single rule that failed, it appears in rule. If an ancestor has several rules and no match, the full breakdown is carried on its own decision.
interface < extends = string> {
: ;
: ;
?: string;
}
Why a feature resolved the way it did.
Full documentation
default-on says the definition enabled the feature and declared no rules.
rule-match says a rule matched, and the decision names that rule.
explicitly-off says the definition set enabled to false.
no-rule-matched says the definition enabled the feature and no rule
passed, and the decision carries the per-rule breakdown. dependency-off
says a parent resolved off, and the decision carries the blocking edge and
the root cause.
No rule and no cascade reads this field to decide enablement.
reason-is-output-only.spec.ts deletes every explanation field from each
parent decision before the next feature sees it and requires the same
enabled for every feature, so a cascade keyed on a reason fails there.
rootCause in evaluate.ts does read a blocked parent’s reason, rule,
rules and blockedBy, and it reads them to fill the dependant’s cause.
A caller who caches a decision as { key, enabled } and hands it back to
the engine keeps every enabled and loses the root cause, with no error.
To determine why a feature resolved to its current state, the engine assigns a Reason. No rule and no cascade reads the field to decide enablement, which reason-is-output-only.spec.ts holds by deleting every explanation field from each parent decision and requiring the same enabled for every feature. The engine does read a blocked parent’s reason, rule, rules and blockedBy to fill the dependant’s cause, so a caller who caches a decision as { key, enabled } and hands it back keeps every enabled and loses the root cause.
default-on: The definition enabled the feature and declared no rules.rule-match: A rule matched, and the decision names that rule.explicitly-off: The definition setenabledtofalse.no-rule-matched: The definition enabled the feature, but no rule passed; the decision carries the per-rule breakdown.dependency-off: A parent resolved off; the decision carries the blocking edge and the root cause.
export type = 'default-on' | 'rule-match' | 'explicitly-off' | 'no-rule-matched' | 'dependency-off';What features.toggle answers.
Full documentation
The ok: true member names the key and repeats the enabled the caller
wrote. It also lists willDisable. The store applies the write before it
answers, so willDisable names every transitive dependant that resolved on
under the previous document and resolves off under the written one, in
dependency order. A caller who enables a feature gets an empty list.
willDisable reads one context, often none, and a dependant carrying rules
or a rollout resolves off under an empty context before the write as well as
after it. mayDisable names those: every transitive dependant the context
left 'deferred' in plan before the write and that the write settles off.
A console confirming a turn-off reads both lists. A caller who enables a
feature gets two empty lists.
The ok: false member answers a key the store does not hold, and the store
wrote nothing. A reload that landed inside the call takes a document that no
longer declares the key down this exit.
When you call features.toggle, the operation returns a ToggleResult.
If the operation is successful (ok: true), the result names the key and repeats the enabled value the caller wrote. It also includes willDisable. The store applies the write before it answers, so willDisable names every transitive dependent that resolved on under the previous document and resolves off under the written one, listed in dependency order. By the time the caller holds the result, features.isEnabled already answers false for every entry on that list. If a caller enables a feature, this list is empty.
The result also includes mayDisable. It names every transitive dependant that plan would mark 'deferred' under the toggle’s context before the write and that the write settles off, in dependency order. A rule, a rollout, or a ruled feature between the dependant and the key left it unsettled for that context, so some context had it on, and the write takes it off for every context. mayDisable shares no key with willDisable, and it is empty when enabling.
If the operation fails (ok: false), it indicates a key the store does not hold, and no data is written.
export type < extends = string> = {
: true;
: ;
: boolean;
: readonly [];
: readonly [];
} | {
: false;
: ;
: 'unknown-feature';
};One subject’s answers for one instant, with the configuration that produced them.
Full documentation
It crosses no service boundary. The process that produced it and the process
that consumes it are two halves of one page load, which is the line § 8 of
docs/specs/2026-09-16-published-policy-contracts.md draws between a
document and a decision.
To carry one subject’s answers from the process that resolved them to the process that renders them, @evanion/feature uses the DecisionSet type. You produce one using features.snapshot(context, options?).
The set states four things:
version: The name of the configuration the decisions resolved under. This is absent for a store built from a definitions literal (which carries no version to state). A consumer compares it with!==and orders nothing.now: The instant the decisions were resolved at, as an ISO 8601 string.origin: ADecisionOrigin.decisions:Decisions<S>, which is deeply readonly for a store built with an observer.
While resolve answers the decisions alone—leaving a consumer in another process unable to recover the version or the instant—a set states both. This allows a server to state the instant once so that no application must pass now by hand.
snapshot reads the installed version before it emits its resolve event. If an observer calls reload on that event, it installs a new document, and version names the one the decisions resolved under.
Because the set states its instant as an ISO 8601 string, snapshot raises a FeatureConfigError naming context/now for a Date that describes no instant. In contrast, resolve, plan and toggle state no instant; they carry that Date into the decision and stay total.
To move a DecisionSet over a wire, you serialize it as JSON. Because JSON.stringify converts a Date to an ISO 8601 string and JSON.parse cannot restore the original object, @evanion/feature ensures that any instant the store itself supplies—such as now or the Date bounding a window condition (field: 'now', op: 'before' or 'after') in a failed rule outcome—is stated as a string, following the same pattern as serializeConfig. While a window bounded by a string or epoch milliseconds retains the value the definition wrote, and resolve keeps the window Date in place, certain literals provided in the definition are not carried across a JSON hop unchanged. A Date used by an AttributeCondition or a variant’s value is carried as the definition wrote it, meaning one JSON cycle turns that Date into a string. When every value in the set is one that JSON writes and reads back unchanged, JSON.parse(JSON.stringify(set)) deep-equals the set.
A decision set crosses no service boundary. The process that produced it and the process that consumes it are two halves of one page load, which is the line § 8 of the published policy contracts spec draws between a document and a decision. To cross a service boundary, you use a FeatureConfig, and you use configDigest to prove two processes hold one.
interface < extends <keyof , VariantInfo | never> = , extends boolean = boolean> {
readonly ?: string | number;
readonly : string;
readonly : ;
readonly : <, <>>;
}
import { } from '@evanion/feature';
const = ([
{
: 'checkout-cta',
: true,
: [
{ : 'control', : 50 },
{ : 'blue', : 50, : { : 'Get it' } },
],
},
]);
// On the server, inside the request that renders the page.
const = .({
: 'cust-0042',
: new ('2026-11-01T09:00:00Z'),
});
.; // -> '2026-11-01T09:00:00.000Z'
.; // -> 'render'
.['checkout-cta'].; // -> 'control'Where a decision set was produced.
To state which kind of process resolved a set, @evanion/feature uses the DecisionOrigin type, which is 'render' | 'build'.
When a set is 'render', it was produced by the request that generated the HTML. Because its instant is only seconds old, the consumer uses it as the default for local resolution. The set records this instant as an ISO 8601 string. Since the now property of ResolvePlanOptions takes an Instant, the consumer passes the value as it is: resolvePlan(features, plan, context, { now: set.now }). This call provides the two pieces of information required to answer the resolution logic: the context carries the subject the deferred entry buckets on, and the option carries the instant.
If the context is empty, the entry lacks the targetingKey named in needs. This triggers a 'missing-field' divergence report and results in every subject receiving the same variant. By including the subject in the context, a window boundary falling between the server’s instant and the client’s call will move no part of the tree.
resolvePlan settles now off the context before it reads the option. If a consumer holds a context that already carries now, they should state the set instant there instead: resolvePlan(features, plan, { ...context, now: set.now }). When provided with such a context, any now value passed in the options argument is discarded.
When a set is 'build', it may be days old. In this scenario, the consumer passes no now value, allowing the deferred remainder to resolve against the client’s own clock.
export type = 'render' | 'build';What a caller passes Features.snapshot beyond the context.
To state anything beyond the context, you can pass a SnapshotOptions to features.snapshot. Its one member is origin, which defaults to 'render'. A build pipeline that writes a set into a bundle passes 'build', because it is the party that knows the instant it stated is not a request’s.
The member states the provenance and moves no resolution. snapshot settles every feature the store holds at the instant the call read, and it reads no freezeTimeAtBuild; thus, a build-origin set states an answer for a now-dependent rule whose definition opted into no frozen window. If a pipeline wants the client to decide such a rule, it calls plan to defer it, and the client calls resolvePlan.
interface SnapshotOptions {
readonly ?: ;
}
import { } from '@evanion/feature';
const = ([
{
: 'checkout-cta',
: true,
: [
{ : 'control', : 50 },
{ : 'blue', : 50, : { : 'Get it' } },
],
},
]);
// On the server, inside the request that renders the page.
const = .({
: 'cust-0042',
: new ('2026-11-01T09:00:00Z'),
});
.; // -> '2026-11-01T09:00:00.000Z'
.; // -> 'render'
.['checkout-cta'].; // -> 'control'What a caller installs to hear about divergence.
To hear about a divergence, you install a DivergenceObserver, which takes one DivergenceReport and answers void. You can provide this at onDivergence in resolvePlan, or as a prop in both providers of @evanion/react-feature.
An observer reads a report and alters no decision. reportDivergence is what calls it, and it discards whatever the observer raises, because an observer that takes down a render is worse than the divergence it was reporting. Because the compiler accepts a function answering anything where a void answer is expected, you can install an async function: async (report) => { await post(report); }. reportDivergence attaches a rejection handler to a thenable answer, which keeps Node from terminating the process over it.
The F parameter is the key type the reports name, defaulting to string. A caller installing an observer on a typed store gets keyof S & FeatureKey.
export type < extends = string> = (: <>) => void;What a process could not reproduce, and why. Output only.
Full documentation
Nothing in the library reads a report back, which is the invariant
reason-is-output-only.spec.ts already holds for reason. A report names
the feature at key when one feature is at fault, and the context field at
field on a 'missing-field' report.
To name what a process could not reproduce, and why, @evanion/feature uses the DivergenceReport type. A report is output only; nothing in the library reads one back, which is the invariant reason-is-output-only.spec.ts already holds for reason.
The kind member names one of four cases:
'config-version': the shipped set and the local store name different configuration versions.'unversioned': one of the two carries no version, so the comparison cannot be made at all.'missing-field': the local context carries no field the shipped answer’s rule read, or a deferred plan entry names a field inneedsthat the context does not carry.'decision-differs': the local resolution answers a differentenabled,reasonorvariantthan the shipped decision.
When a single feature is at fault, the report names that feature at key. If the report is a 'missing-field' type, the context field is named at field.
The report tracks where the divergence occurred using the shipped and local members. Both are optional and hold a DivergenceSide if the report has a side to state; a reader guards both before reading through them. To help the developer, the message member provides one sentence naming the cause and the fix, written for a developer reading a console.
Different tools generate different reports. Because resolvePlan holds only a plan and a context, it reports 'missing-field' and nothing else, filling neither side. The other three cases come from the @evanion/react-feature provider, which holds a shipped set beside a local store.
interface < extends = string> {
: 'config-version' | 'unversioned' | 'missing-field' | 'decision-differs';
?: ;
?: string;
?: DivergenceSide;
?: DivergenceSide;
: string;
}
One side of a comparison, at the members a report names.
To state one side of a comparison, @evanion/feature uses the DivergenceSide type.
The DivergenceSide type allows @evanion/feature to isolate the values provided by a server from those provided by a local process. The shipped and local members of a DivergenceReport are both optional; a report fills a side only where it had one to state. Where it fills both, an observer reads what the server answered beside what the local process answers.
Depending on the reporter, a report may fill these sides differently:
resolvePlan: Fills neither side. Because it holds a plan and a context, its'missing-field'report names the feature atkeyand the field atfieldand states no answer on either side. An observer written for it readskey,field, andmessage. If an observer attempts to readreport.shipped.version, it raises aTypeError, whichreportDivergencediscards, leaving the divergence unreported.@evanion/react-feature: Holds a shipped set beside a local store. It attaches both sides for a'config-version', an'unversioned', and a'decision-differs'report. It attachesshippedalone on the two'missing-field'reports where there is no local answer to state: either the store declares no feature the server shipped a variant for, or it declares that feature with no variants at all.
A report fills the members of a side based on its kind. All four members of a side are optional:
- A
'config-version'report fillsversionon both sides. Each process named a version, and the two differ. - An
'unversioned'report attaches both sides and leavesversionabsent on whichever side carried none, which is the condition the report exists to name. Both are absent where neither process holds a versioned document. So an observer that has narrowed a report to a version kind still guardsversionitself before it reads through it, or it raises theTypeErrorreportDivergencediscards. - A variant report fills
variantand thesourcethat assigned it (one of'weighted','pinned','sticky', or'fallback', the four aDecision’sassignmentcarries). - A
'decision-differs'report fillsenabledas well.
interface DivergenceSide {
?: string | number;
?: boolean;
?: string;
?: 'weighted' | 'pinned' | 'sticky' | 'fallback';
}
One plan entry per feature, guarded on never exactly as Decisions is.
For Build-time planning, @evanion/feature uses Plan, which contains one plan entry per feature. This uses the same never guard as Decisions, ensuring a feature declaring no variants receives a plan entry whose decision contains no variant or value keys.
S defaults to Schema, so the bare Plan holds what plan() answers on a store built with no observer, at any schema, and on a store typed Features<Schema, false>.
To understand how types change when a store is observed, consider how @evanion/feature handles the Frozen state. A store held in the bare Features slot, or built by parseFeatureConfig, leaves Frozen at boolean, and its plan() answers Plan | DeepReadonly<Plan>.
This creates a type mismatch because the deeply readonly member types the Date a failed window condition in a plan entry’s decision can carry as a ReadonlyDate, which the mutable form refuses; consequently, the bare Plan does not accept that union and the compiler reports TS2322. To resolve this, a build step that writes the plan of such a store names it FrozenWhenObserved<boolean, Plan>, which is the type plan() answers there. The example under Features<S> shows that build step.
export type < extends <keyof , VariantInfo | never> = > = {
[ in keyof ]: [
[]
] extends [
never
] ? << & >, 'decision'> & {
?: <, []>;
} : [] extends VariantInfo ? < & , []['variant'], []['value']> : never;
};One feature’s build-time plan.
A PlanEntry represents a single feature’s build-time plan.
The resolved member is true, false, or 'deferred'. A value of 'deferred' indicates that a rule or a variant split still requires context that the plan did not have. In this case, needs lists the required context fields, sorted; otherwise, needs is empty.
A decision is present whenever resolved is a boolean. It may also be present in a deferred entry if the enablement is settled but the variant split is outstanding. In that case, the decision carries no variant because the context lacked the bucketing field. Consequently, a caller cannot use the presence of a decision as proof that resolved is a boolean.
interface < extends = string, extends string = string, = unknown> {
: ;
: boolean | 'deferred';
: readonly string[];
?: <, , >;
}
Turns a build-time plan plus a client context into a full decision set.
Full documentation
Three rules, one per kind of entry. A boolean resolved carrying a decision
is the answer and nothing re-runs, because the build already decided and a
client that re-decides throws away the point of planning. A 'deferred'
entry carrying no decision goes through decide against the client context.
A 'deferred' entry carrying a decision has its enablement settled and its
variant outstanding, so the enablement comes off the entry and only the
assignment is computed; a second run of the rules against a richer client
context can flip enablement, which would contradict the entry. A parent that
resolves off in this pass blocks a settled entry and a deferred one alike,
because an entry settled the feature’s own rules and not the graph above it,
and a set naming a feature on under a parent it names off contradicts
itself.
The walk is the dependency order, because a deferred parent puts its needs
on its dependants and a dependant resolved before its parent would read an
empty parent map.
To turn a build-time plan and a client context into a full decision set, you use the resolvePlan function.
The function processes entries based on their resolution state:
- Settled: If
resolvedis a boolean and a decision is present, that decision is used as the answer without re-running rules. - Deferred (no decision): The rules are run against the client context.
- Deferred (with decision): Enablement is read from the entry and only the assignment is computed. This prevents a second run of the rules against a richer context from flipping the enablement and contradicting the original plan entry.
If a parent resolves off during this pass, it blocks both settled and deferred entries alike. An entry settled the feature’s own rules and not the graph above it, and a set that claims a feature is on while its parent is off contradicts itself. The walk follows the dependency order, because a deferred parent puts its needs on its dependents.
function < extends <keyof , VariantInfo | never>>(: <>, : <<>> | <<<>>>, ?: EvaluationContext, ?: <keyof & >): <>;
What the tests state
46 behaviours
resolvePlan
resolves a settled entry that carries no decision
Reading the case from the package.
Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.
import { , } from '@evanion/feature';
const = ([
{
: 'checkout-cta',
: true,
: [
{ : 'control', : 50 },
{ : 'blue', : 50 },
],
},
]);
// At build time, with no subject to bucket on.
const = .({ : new ('2026-11-01T00:00:00Z') });
['checkout-cta'].; // -> 'deferred'
['checkout-cta'].; // -> ['targetingKey']
// In the client, where the subject is known.
const = (, , { : 'cust-0042' });
['checkout-cta'].; // -> 'control'
['checkout-cta'].?.; // -> 'weighted'What a caller passes resolvePlan beyond the plan itself.
To configure how resolvePlan behaves, you pass ResolvePlanOptions.
The now member specifies the instant the deferred remainder resolves. This prevents a window boundary between server and client from shifting half the tree:
- A
DecisionSetwithorigin: 'render'states an instant seconds old; the caller passes it here to maintain consistency. - A
DecisionSetwithorigin: 'build'may be days old; the caller passes nothing, andresolvePlanuses the client’s clock. - If the context passed to
resolvePlanhas its ownnowvalue, it takes precedence over this option.
To handle missing data during resolution, you provide a callback to the onDivergence member. This callback receives a missing-field report for every field a deferred entry names in needs that the context you pass resolvePlan does not carry, with one exception: now.
resolvePlan always has a now. It takes the one on the context, failing that the one on ResolvePlanOptions, and failing both the client’s own clock. A deferred entry whose needs names now therefore resolves against a real instant and reports nothing. A window rule on a feature that did not set freezeTimeAtBuild defers on now, so that entry is the plan working as designed and not a divergence.
interface < extends = string> {
?: ;
?: <>;
}
The decisions a process publishes from a set another process resolved, with every divergence reported before the caller reads one.
Full documentation
A mismatch publishes the shipped set. The server already rendered from it and recorded an exposure for the variant it shipped, so a consumer that quietly resolves a third answer makes every experiment result wrong by an amount nobody can measure from the data. The consumer also cannot tell which side is stale: a mismatch says the two envelopes differ, and the consumer’s copy is the older one as often as the newer.
To sync a process with decisions resolved by another process, you use adoptDecisions(features, shipped, options?). This function takes a DecisionSet from another process and answers the decisions this process publishes. It imports no framework and runs in any program, though @evanion/react-feature’s providers call it.
If shipped is left out, @evanion/feature answers features.resolve(options.context).
When provided with a set, adoptDecisions runs two checks before returning, reporting results through onDivergence:
- Version Check: It compares the set’s
versionwithfeatures.versionusing!==. If one side carries no version, it reports'unversioned'; if two versions differ, it reports'config-version'. - Field Check: It reports
'missing-field'for every shipped decision the local process’s context cannot reproduce. This includes features the store does not declare, features declared with no variants, bucketing fields the context lacks, or fields a pinning rule reads that the context lacks. It never namesnow.
The return value depends on the version outcome: on a 'config-version' mismatch, it returns the shipped decisions under 'use-shipped' and resolves locally under 're-resolve'. An 'unversioned' outcome always returns the shipped decisions.
If compare: true and an observer is installed, @evanion/feature resolves locally once and reports 'decision-differs' for every key where the enabled, reason, or variant differs. Because a store built with observe emits a 'resolve' event, the local resolution happens at most once per call, and only when the comparison or 're-resolve' reads the result.
The timing of local resolutions depends on the set type: for a 'render' set, now is the default instant if options.context states none; for a 'build' set, the instant is ignored and the local clock answers.
function < extends <keyof , VariantInfo | never> = >(: <>, : <> | undefined, ?: <keyof & >): <> | <<>>;
What the tests state
50 behaviours
adoptDecisions
the instant a local resolution reads
the version comparison
the local resolution budget
the bucketing check
the pin check
the comparison
an observer that raises
publishes the shipped decisions when the two versions match
Reading the case from the package.
Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.
import {
,
,
,
} from '@evanion/feature';
import type { , } from '@evanion/feature';
const = [
{
: 'checkout-cta',
: true,
: [
{ : 'control', : 50 },
{ : 'blue', : 50 },
],
},
];
const = ((), { : 'v1' });
const = ((), { : 'v0' });
// The server resolves the set inside the request.
const = ();
const = .(.({ : 'cust-0042' }));
// The client parses it and adopts it with a store built from the same document.
const = .() as ;
const : [] = [];
const = ();
const = (, , {
: { : 'cust-0042' },
: () => .(),
});
['checkout-cta']?.; // -> 'control'
.; // -> 0
// A client still holding the previous document reports the mismatch.
const = ();
(, , {
: { : 'cust-0042' },
: () => .(),
});
.(() => .); // -> ['config-version']
[0]?.; // -> "these decisions were resolved under config version v1 and this store holds v0, so the two processes hold different configurations. Reload the store from the document the server used, or pass onVersionMismatch: 're-resolve'."What a caller passes adoptDecisions beyond the set itself.
To configure how decisions are adopted, you pass an AdoptOptions object to adoptDecisions.
contextis the evaluation context held by this process.onVersionMismatchdefaults to'use-shipped'.onDivergenceis aDivergenceObserverand alters no decision.comparedefaults tofalse. Because the core reads no host global to determine this, the caller must state it. For example,@evanion/react-featurepassestrueoutside a production bundle.
interface < extends = string> {
?: EvaluationContext;
?: ;
?: <>;
?: boolean;
}
What a consumer does when the shipped version is not the store’s.
When the shipped version does not match the store’s, you define the behavior using a VersionMismatchPolicy ('use-shipped' | 're-resolve').
'use-shipped'publishes the shipped decisions, as the server already rendered from them and recorded an exposure for that variant.'re-resolve'resolves against the local store.
@evanion/react-feature re-exports this type for the onVersionMismatch prop of its providers.
export type = 'use-shipped' | 're-resolve';An entry point reports this event after it has answered.
Full documentation
To track how features are accessed, the engine produces one event for each
resolve, isEnabled, plan, toggle, and snapshot call. Other members
do not report events: variantOf and valueOf read a field off a resolution
the engine ran internally, and isEnabled reports nothing for a key the
store was not built with.
Because snapshot reports the same 'resolve' event that resolve reports,
four type values cover the five calls. To distinguish the scope of a
request, resolve reports every decision in one event rather than reporting
one event per feature. This distinction allows an auditor reading a resolve
event to know the application asked for every feature, while an auditor
reading an is-enabled event knows it asked for only one.
The event payload is the record the call produced, though this is not always
the value the caller received. While resolve, plan and toggle hand the
caller the same object the event carries, isEnabled hands the caller a
boolean and the event carries the decision that boolean was read from.
Similarly, snapshot hands the caller a DecisionSet but the event carries
only its decisions: the event has no origin, its at is a Date where
the set states now as an ISO 8601 string, and its version is the
version option of createFeatures where the set’s version names the
installed document.
To ensure an observer reads the payload and changes no outcome, every payload
is deeply readonly. The compiler refuses
event.decisions.cta.enabled = false for a TypeScript consumer, and for a
JavaScript consumer who ignores the type, the engine freezes what it emits.
Finally, no event carries an EvaluationContext. An event carries the
instant the call settled on, its payload, and a subject identifier copied out
as a primitive.
To track how features are evaluated in a running application, @evanion/feature provides a way to observe the outcome of store calls via a FeatureEvent.
export type < extends <keyof , VariantInfo | never> = <, VariantInfo | never>> = {
readonly : 'resolve';
readonly : ;
readonly : <<>>;
readonly ?: string | number;
readonly ?: string;
} | {
[ in keyof & ]: {
readonly : 'is-enabled';
readonly : ;
readonly : ;
readonly : <<>[]>;
readonly ?: string | number;
readonly ?: string;
};
}[keyof & ] | {
readonly : 'plan';
readonly : ;
readonly : <<>>;
readonly ?: string | number;
readonly ?: string;
} | {
readonly : 'toggle';
readonly : ;
readonly : <<keyof & >>;
readonly ?: string | number;
readonly ?: string;
};import { } from '@evanion/feature';
import type { } from '@evanion/feature';
const : [] = [];
const = (
[
{ : 'new-checkout', : true },
{ : 'gift-cards', : true },
],
{
: () => {
.();
},
},
);
const = new ('2026-11-01T00:00:00Z');
.('gift-cards', { : 'cust-0042', });
.; // -> 1
[0]; // -> { type: 'is-enabled', at: new Date('2026-11-01T00:00:00Z'), subject: 'cust-0042', key: 'gift-cards', decision: { key: 'gift-cards', enabled: true, reason: 'default-on' } }Reporting Events
The @evanion/feature store emits one event for each resolve, isEnabled, plan, toggle, and snapshot call. No other member reports; variantOf and valueOf read a field from an internal resolution and emit nothing, and isEnabled emits nothing for a key the store was not built with.
The type member names the payload, using four values to cover the five reporting calls:
'resolve': Emitted byresolve. It carries every decision atdecisions, the same recordresolvereturned.'is-enabled': Emitted byisEnabled. It carries thekeyrequested and, atdecision, the decision the booleanisEnabledreturned was read from.'plan': Emitted byplan. It carries atplanthe partitionplanreturned.'toggle': Emitted bytoggle. It carries atresultthe reporttogglereturned.
Snapshot Events
When snapshot is called, the store reports a 'resolve' event. Because of this, the event does not say which of the two calls ran.
The event carries the set’s decisions record and nothing else from the DecisionSet the caller received. It has no origin. Its at is a Date naming the same instant as the set’s ISO 8601 now.
The version handling differs between the event and the set: the event’s version is the version option of createFeatures, while the set’s version is the version of the document the store had installed. Consequently, a store built from a versioned document with no version option emits a snapshot event with no version at all. An auditor who matches snapshot events to the sets a server shipped has subject, the instant and the decisions to join on.
Type Safety and Metadata
To ensure a consumer who narrows on key cannot read a decision belonging to another feature, the 'is-enabled' member is written as a mapped type the union closes over; this pairs key: 'checkout-cta' with the decision that key resolves to, rather than using a flat object typing key and decision as unions of every possible value.
Common metadata is shared across all events:
- Context: No event carries an
EvaluationContext. - Timing: The
atmember is the instant the call settled on. - Identity:
subjectis the identifiercorrelateBynames, copied out as a primitive. - Versioning:
versionis theversionoption passed tocreateFeatures, absent when the store was given none.
Every payload is deeply readonly, so the compiler refuses a write to it and the engine freezes what it emits. See Observing.
What an application installs at construction.
To install an observer and name the fields an event reports, you pass FeatureOptions to createFeatures as its second argument. The interface declares four optional members:
observe: called once for each call that reports, with theFeatureEventthat call produced. The engine never awaits it.onObserveError: replaces theconsole.warnthe engine writes when an observer throws or rejects.correlateBy: the context field whose value identifies the subject on an event. Defaults totargetingKey.version: the configuration version an event reports.
Because FeatureOptions declares observe optionally, options carrying an observer and options carrying none both satisfy it. UnobservedOptions is the form that proves the absence.
The overhead of observation is minimal. Measured at forty features on an Apple M1 Pro under Node 24.20.0, the bare call ran at 23 microseconds, a synchronous no-op observer at 41, and an async no-op observer (including the thenable check and rejection handler) at 44. The deep freeze a store carrying an observer runs accounts for that difference, and the emit and the thenable check stay within run-to-run noise of the bare call. So the number anyone should care about is what their own hook body does.
interface < extends <keyof , VariantInfo | never> = <, VariantInfo | never>> {
?: (: <>) => void | <unknown>;
?: (: unknown, : <>) => void;
?: string;
?: string;
}
import { } from '@evanion/feature';
import type { } from '@evanion/feature';
const : [] = [];
const = (
[
{ : 'new-checkout', : true },
{ : 'gift-cards', : true },
],
{
: () => {
.();
},
: '2026-11-01',
},
);
const = new ('2026-11-01T00:00:00Z');
.({ : 'cust-0042', });
.; // -> 1
[0]; // -> { type: 'resolve', at: new Date('2026-11-01T00:00:00Z'), subject: 'cust-0042', version: '2026-11-01', decisions: { 'new-checkout': { key: 'new-checkout', enabled: true, reason: 'default-on' }, 'gift-cards': { key: 'gift-cards', enabled: true, reason: 'default-on' } } }observe does not count exposures
You should not use the observer to track feature exposures. Because resolve decides every configured feature, an observer fired from it reports a decision for every feature the request never rendered; an experiment computed over those records measures the wrong denominator. The numbers look plausible and are wrong. The application should write an exposure record at its render site, using the fields the decision it already holds carries. See Observing for the whole argument.
Records in flight are lost at exit
Because the engine awaits nothing, any buffer the application has not drained is lost when the process exits or when a phone’s operating system suspends a process without warning. To prevent this, the application must drain that buffer on SIGTERM and on the background transition. Each runtime carries its own transport: a service writes to a log collector, and a browser writes with navigator.sendBeacon on visibilitychange. See Observing for the hook shape.
The options of a call that provably installs no observer.
Full documentation
FeatureOptions declares observe optionally, so an argument that carries
an observer and an argument that carries none both satisfy it. This form
pins the member to undefined, so only an argument whose type proves the
absence satisfies it.
The distinction decides which store a call answers, and the answer leans one
way when the compiler cannot settle it. The pair of signatures that takes a
named schema selects on this type: options satisfying it answer the mutable
form, and FeatureOptions<S> answers the frozen one. The inferring signature
asks whether the options type names observe at all, so an object holding
observe: undefined answers the frozen form there even though it satisfies
this type. Options that omit the member reach the mutable form on both paths.
An options value typed FeatureOptions<S>, and an object literal whose
observe field holds Fn | undefined, both fail this type, so both answer
the frozen form. A wrapper forwarding an optional parameter cannot annotate
its way past that, because the forwarded parameter’s type is not undefined.
The compiler then refuses a write the runtime would refuse too. The opposite
lean lets the compiler accept a write that throws.
To let the compiler prove that a call installs no observer, @evanion/feature uses the UnobservedOptions type, which is FeatureOptions with observe pinned to undefined.
Only a value whose type proves the absence satisfies it, and that is the type the second createFeatures signature takes. A call that reaches that signature gets a store whose resolve, plan and toggle answer the mutable form.
An options value typed FeatureOptions<S>, and an object literal whose observe field holds Fn | undefined, both fail this type and therefore answer the frozen form. A wrapper forwarding an optional parameter cannot annotate its way past that, because the forwarded parameter’s type is not undefined.
export type < extends <keyof , VariantInfo | never> = <, VariantInfo | never>> = <> & {
?: undefined;
};The deeply readonly form of T for a store that freezes what it answers, and
T itself for a store that freezes nothing.
Full documentation
Frozen is naked, so a store whose observation the compiler cannot settle
distributes to the union of both forms and a reader narrows it.
To type an answer whose readonly-ness the store decides, @evanion/feature uses the FrozenWhenObserved type.
The store behaves differently under two configurations: a store carrying an observer freezes what it emits and answers the caller with DeepReadonly, while a store carrying none freezes nothing and answers the type unwrapped.
The engine takes that asymmetry deliberately to avoid unnecessary overhead. At forty features, the freeze adds 18 microseconds to a 23 microsecond resolve; a freeze on every call would penalize every user of the package whether or not they observe anything. Since a store nobody observes hands its record to nobody else, the freeze protects nothing there.
Because Frozen is naked in FrozenWhenObserved, a store whose observation the compiler cannot settle distributes to the union of both forms, and the reader narrows it.
export type < extends boolean, > = extends true ? <> : ;Every property readonly, through arrays and nested objects.
Full documentation
A Date becomes a ReadonlyDate. A function passes through, because
mapping over its properties erases the call signature.
To mark every property readonly through arrays and nested objects, @evanion/feature uses the DeepReadonly type. In this process, a Date becomes a ReadonlyDate. Functions pass through unchanged, because mapping over their properties would erase the call signature.
export type <> = extends Date ? : extends (...: never[]) => unknown ? : extends readonly (infer )[] ? readonly <>[] : extends object ? {
readonly [ in keyof ]: <[]>;
} : ;A Date with its mutators removed.
Full documentation
Readonly<Date> marks the properties readonly and leaves every setTime,
setHours and setFullYear callable, and each of those writes an internal
slot that Object.freeze does not cover. Every mutator on Date starts with
set, so the template literal names all of them and Omit drops them.
To carry a Date a reader cannot move, @evanion/feature uses the ReadonlyDate type, which drops the mutators.
This is necessary because Readonly<Date> marks properties readonly but leaves setTime, setHours and others callable; each of those writes an internal slot that Object.freeze does not cover. To prevent this, the ReadonlyDate type uses a template literal to name all mutators starting with set and Omit to drop them.
export type = <Date, `set${string}`>;A feature identifier.
Full documentation
string | number rather than string, so a consumer can key features on a
numeric enum and still get exhaustiveness from Decisions.
To identify a feature in your configuration, you use a FeatureKey. A definition may carry key: 7. A numeric key union goes in a schema, Record<Flag, VariantInfo | never>, and Decisions takes that schema. Decisions<Flag> over the key union on its own is a compile error, since Decisions<S> constrains S to Record<keyof S, VariantInfo | never>.
import { } from '@evanion/feature';
enum {
= 1,
= 2,
}
const = ([
{ : ., : true },
{ : ., : false },
] as );
.(.);export type = string | number;A point in time a window condition compares against. A number is epoch milliseconds.
Full documentation
A string is ISO 8601 and names one instant on every host: a date-time
carrying Z or an explicit offset, or a date with no time, which ECMA-262
fixes to UTC. createFeatures rejects every other string, because ECMA-262
reads a date-time carrying no offset as local time and the rule then answers
one way in Stockholm and another in Tokyo under one derived id.
To represent a precise moment in time for conditions, @evanion/feature uses the Instant type. A number is epoch milliseconds. A string is ISO 8601 and has to name one instant on every host, which two forms do: a date-time carrying Z or an offset, and a date with no time, which ECMA-262 fixes to UTC. The offset takes either spelling ISO 8601 gives it.
| Instant string | Read as |
|---|---|
2026-01-01T00:00:00Z | that instant |
2026-01-01T09:00:00+09:00 | the same instant |
2026-01-01T09:00:00+0900 | the same instant |
2026-01-01 | UTC midnight |
2026-01-01T00:00:00 | FeatureConfigError at construction |
2026-13-01 | FeatureConfigError at construction |
2026-01-01t00:00:00z | FeatureConfigError at construction |
2026-04-31 | FeatureConfigError at construction |
ECMA-262 reads 2026-01-01T00:00:00 as local time. The rule derives one id on every host and resolves against a different boundary on each, so a browser in Los Angeles and a service in Stockholm disagree about a decision that carries the same name in both logs. The two rows under it sit outside the format ECMA-262 fixes, one naming no calendar date and one written in lower case, and each engine reads them by rules of its own. 2026-04-31 sits inside the format and every engine carries the overflowing day into 1 May, so a string naming April names May on every host, under the one rule id 2026-05-01 also derives. createFeatures refuses all four.
| Instant value | Read as |
|---|---|
1767225600000 | that instant |
new Date(1767225600000) | that instant |
Number.NaN | FeatureConfigError at construction |
Number.POSITIVE_INFINITY | FeatureConfigError at construction |
8640000000000001 | FeatureConfigError at construction |
new Date('nonsense') | FeatureConfigError at construction |
A number has to be an epoch a Date holds, and a Date has to hold an instant. createFeatures refuses Number.NaN, which is what Date.parse and new Date answer for a string naming none, so a boundary computed from an unset environment variable is refused where it is written. It refuses a number past either end of the range a Date holds, -8640000000000000 to 8640000000000000, since a before boundary above that range holds at every instant and an after boundary at none.
A Date is taken as the instant it holds and is read no further, so new Date('2026-01-01T00:00:00') carries the constructing host’s zone past every check. Build one from epoch milliseconds, or from a string the table accepts.
To transmit an instant over the wire, @evanion/feature uses the SerializedInstant type. This wire form admits strings and numbers but refuses Date objects.
export type = string | number | Date;The canonical configuration document: an envelope over a list of definitions.
Full documentation
There is no bare-array form, for the reason Matrix at
libs/acl/src/types.ts:220-234 gives: a foreign producer emitting JSON
states the version and the schema, and one document crosses a boundary
without a wrapper assembled at the call site.
F defaults to FeatureKey. An entry point that reads a whole document
names the bare FeatureConfig, and a document keyed on a numeric enum
reaches that parameter only while the default admits a number.
To transport feature definitions across a boundary, you use a configuration document. The FeatureConfig interface types that document: a canonical envelope over a list of definitions.
There is no bare-array form, for the reason Matrix gives in @evanion/acl. By using an envelope, a foreign producer emitting JSON can state the version and the schema, ensuring one document crosses a boundary without needing a wrapper assembled at the call site.
The version is compared with !==, and it is opaque and unordered. A consumer that could order two versions would act on the order, and both available actions are wrong: declining a lower version blocks a rollback (the operation an operator reaches for at 3am), and accepting only a higher version breaks a control plane serving two shards whose counters diverged. A consumer holding a version different from the one it wants fetches the document that version names.
The digest is the configDigest of this document, and a holder that finds one verifies it. The schema carries the typing contract inline and schemaVersion names the schema’s own version; a document may name schemaVersion alone. The features member holds one SerializedDefinition per feature.
The maxStale value indicates how long the publisher believes a holder may keep this document, in milliseconds. This is advisory and no entry point in @evanion/feature reads it. The party that fetches is the party that acts on it: whatever reads the document holds the poller, knows the fetch instant because it performed the fetch, and shortens its interval, logs, refuses to start or serves a fallback document. This library holds no clock authority, and no endpoint answers which variant a subject gets, so every holder computes its own answer from the rules the document carries.
The F parameter defaults to FeatureKey. An entry point that reads a whole document names the bare FeatureConfig, and a document keyed on a numeric enum reaches that parameter only while the default admits a number.
See Distribution for what a document carries.
interface < extends = > {
readonly ?: string | number;
readonly ?: string;
readonly ?: FeatureSchema;
readonly ?: string;
readonly ?: number;
readonly : readonly <>[];
}
import { , } from '@evanion/feature';
const = ([
{ : 'new-checkout', : true },
{ : 'express-pickup', : true, : ['new-checkout'] },
]);
const = (, {
: '2026-10-08.1',
: 60_000,
});
.; // -> '2026-10-08.1'
.; // -> 60000
..; // -> 2
.; // -> undefinedThe envelope without its payload, which a serializer writes around a store.
Full documentation
digest is fenced to never rather than omitted. Omitting it drops the
member from the type and still admits a whole FeatureConfig, because
TypeScript refuses an extra property on a fresh object literal and nowhere
else. A document’s digest is always configDigest of that same document,
so a serializer that copied one from its caller would emit a document whose
digest covers other bytes. Every holder recomputes it,
disagrees, reports digest-mismatch and refuses the whole document, and no
holder recovers on its own. configDigest is the one writer of the member.
When a serializer writes around a store, it uses ConfigEnvelope to define the envelope without its payload. It is FeatureConfig with features and digest dropped, and digest written back as never. serializeConfig takes one as its second argument and defaults to features.envelope, the envelope of the document the store installed.
The digest is fenced to never rather than omitted. Omitting it would drop the member from the type and still admit a whole FeatureConfig, because TypeScript refuses an extra property on a fresh object literal and nowhere else. Because a document’s digest is always the configDigest of that same document, a serializer that copied one from its caller would emit a document whose digest covers other bytes. Every holder recomputes it, disagrees, reports digest-mismatch and refuses the whole document, and no holder recovers on its own. configDigest is the one writer of the member.
export type = <, 'features' | 'digest'> & {
readonly ?: never;
};import { , } from '@evanion/feature';
const = ([
{ : 'new-checkout', : true },
{ : 'express-pickup', : true, : ['new-checkout'] },
]);
const = (, {
: '2026-10-08.1',
: 60_000,
});
.; // -> '2026-10-08.1'
.; // -> 60000
..; // -> 2
.; // -> undefinedWhat validateConfig decided. The refusal carries every issue it found.
To determine if a configuration is valid, you use validateConfig, which returns a ValidationResult. This result is discriminated on ok. The agreement arm is { ok: true } and carries nothing else. The refusal arm carries every issue the walk found, so a caller reporting a bad document names all of its defects in one pass.
export type = {
: true;
} | {
: false;
: readonly ConfigIssue[];
};import { } from '@evanion/feature';
import type { } from '@evanion/feature';
const : = {
: 42,
: [
{ : 'new-checkout', : true },
{ : 'new-checkout', : true },
{ : 'express-pickup', : true, : ['nowhere'] },
],
};
const = ();
.; // -> false
. ? [] : ..(() => .); // -> ['duplicate-feature', 'unknown-dependency']
. ? [] : ..(() => .); // -> ['new-checkout', 'express-pickup']One defect validateConfig found, with enough detail to name the row.
When validateConfig finds a defect, it reports it as a ConfigIssue. The code names the class of defect and message is the same text the thrown counterpart carries, where one exists. The key names the feature the issue is about, when it is about one. The path is a JSON pointer into the document, so a UI highlights the row.
A duplicate-feature or unknown-dependency issue names a key, a cycle issue names none, and no graph issue names a member.
interface ConfigIssue {
: ;
: string;
?: ;
?: string;
}
import { } from '@evanion/feature';
import type { } from '@evanion/feature';
const : = {
: 42,
: [
{ : 'new-checkout', : true },
{ : 'new-checkout', : true },
{ : 'express-pickup', : true, : ['nowhere'] },
],
};
const = ();
.; // -> false
. ? [] : ..(() => .); // -> ['duplicate-feature', 'unknown-dependency']
. ? [] : ..(() => .); // -> ['new-checkout', 'express-pickup']What validateConfig found wrong. One code per class of defect.
ConfigIssueCode is the closed set of 18 codes, one per class of defect, in four groups: the graph, the variants, the rules and the envelope. The validateConfig entry lists them.
export type = 'duplicate-feature' | 'unknown-dependency' | 'cycle' | 'duplicate-variant' | 'unknown-variant' | 'invalid-weight' | 'empty-variants' | 'zero-weights' | 'duplicate-rule-id' | 'duplicate-variant-order' | 'invalid-variant-order' | 'invalid-instant' | 'unknown-context-field' | 'field-type-mismatch' | 'unfenced-schema' | 'missing-schema-version' | 'unknown-member' | 'digest-mismatch';import { } from '@evanion/feature';
import type { } from '@evanion/feature';
const : = {
: 42,
: [
{ : 'new-checkout', : true },
{ : 'new-checkout', : true },
{ : 'express-pickup', : true, : ['nowhere'] },
],
};
const = ();
.; // -> false
. ? [] : ..(() => .); // -> ['duplicate-feature', 'unknown-dependency']
. ? [] : ..(() => .); // -> ['new-checkout', 'express-pickup']What a reload did.
Full documentation
Both arms carry version, and on both arms it names the document that is
installed now. A refusal keeps the previous document, so its version is the
one that stayed and rejected is the one that did not arrive.
To track the outcome of a configuration update, you use ReloadResult, which describes what a reload did and is discriminated on ok. Both arms carry version, and on both arms it names the document that is installed now.
The agreement arm carries previousVersion, the version that was installed before, and changed, the keys whose stored intent differs from the previous document.
The refusal keeps the previous document, so its version is the one that stayed and rejected is the candidate’s version, which lets a log name what was refused. The issues member carries what validateConfig found.
export type = {
: true;
: string | number | undefined;
: string | number | undefined;
: readonly [];
} | {
: false;
: string | number | undefined;
: string | number | undefined;
: readonly ConfigIssue[];
};import { } from '@evanion/feature';
const = ({
: 41,
: [
{ : 'new-checkout', : true },
{ : 'express-pickup', : true, : ['new-checkout'] },
],
});
const = .({
: 42,
: [
{ : 'new-checkout', : true },
{ : 'express-pickup', : false, : ['new-checkout'] },
],
});
; // -> { ok: true, version: 42, previousVersion: 41, changed: ['express-pickup'] }
.('express-pickup'); // -> falseThe typing contract a document states.
Full documentation
Two vocabularies, because the two halves describe different things. For a
context field, validateConfig checks that context.fields declares the
field and that the condition’s operator fits the declared type’s []
suffix. It compares no condition value with the declared base name, so a
condition over a field declared number carrying a string passes. The
engine reads no variant value at all.
To define the typing contract of a document, you use the schema member, which is typed by FeatureSchema. This contract consists of two halves, each with its own vocabulary. The context member declares the fields a condition may read and is typed by ContextSchema. Conversely, features[key].variants[name] declares the shape of a variant’s value and is typed by ValueShape.
The @evanion/feature engine applies distinct logic to these two vocabularies. When handling a context field, validateConfig focuses on structure rather than data: it verifies that the schema declares the field and that the condition’s operator matches the declared type’s [] suffix. Because the engine never compares the condition’s value against the declared base name, a condition over a field declared number will still pass even if it carries the string 'gold'. In contrast, the engine remains entirely passive regarding variant values, as valueOf simply hands the value to the caller untouched.
If a document carries neither schema nor schemaVersion, it declares no shapes, and the schema walk reports nothing about it. If a document carries an inline schema but no schemaVersion, it reports missing-schema-version, because a holder cannot cache a schema that names no value to cache it at.
interface FeatureSchema {
readonly ?: ContextSchema;
readonly ?: <<string, FeatureShape>>;
}
The context fields rules read.
To manage the available context fields in @evanion/feature, you use ContextSchema, where fields maps a context field’s name to its FieldType. The validateConfig function reads this map twice over. If a condition is used over a field that the map omits, it reports unknown-context-field. If an operator is used that the declared type has no meaning for, it reports field-type-mismatch. For example, using contains over a field declared boolean asks whether a boolean holds a value, which evaluateCondition answers false for every subject. A document whose schema omits context.fields declares no contract, and neither check runs. An empty map declares zero fields, so a condition over any field reports unknown-context-field.
interface ContextSchema {
readonly ?: <<string, >>;
}
One context field’s declared type.
Full documentation
A flat string, so the whole schema is JSON a producer in any language emits
by reflection. [] is an array of the base type, and a trailing ? marks a
field a complete context may leave out.
The FieldType used in @evanion/feature is a template-literal union over BaseFieldType, admitting sixteen strings: each of the four base names on its own, each with [], each with ?, and each with []? in that order. Consequently, instant[]? is a member while instant?[] is not.
The [] suffix is what field-type-mismatch reads, since contains asks whether an array holds a value, while eq, ne, in and not-in compare a scalar. The check strips a trailing ? before it tests for [], so string[]? names an array to it and contains over such a field passes. The ? suffix also marks a field that a complete context may leave out, and no issue code reports a field a context omitted, so a generator and a reader are what read it for that.
export type = | `${}[]` | `${}?` | `${}[]?`;The base of a declared context field type.
Full documentation
Ported from libs/acl/src/types.ts:157-171. instant is a point in time
carried as an ISO 8601 string or epoch milliseconds, which is what toEpoch
at conditions.ts:18-22 already accepts.
BaseFieldType defines the four base names that a FieldType builds on. Among these, instant represents a point in time that the context carries as an ISO 8601 string or as epoch milliseconds.
export type = 'string' | 'number' | 'boolean' | 'instant';What a schema records about one feature.
To record the schema for a specific feature, @evanion/feature uses FeatureShape, which is held under schema.features at the feature’s own key. Within this, variants maps a variant name to that variant’s ValueShape in no significant order. The document’s own variants array holds the order, and VariantSpec.order fixes where a variant sits in the bucketing walk.
interface FeatureShape {
readonly ?: <<string, >>;
}
A JSON Schema 2020-12 object, fenced to the subset validateConfig accepts.
Full documentation
The engine never reads a variant value: valueOf hands it to the caller
untouched. So this shape is what a code generator and an optional validator
read, and the notation is JSON Schema because quicktype and
openapi-generator already emit TypeScript, Swift and Kotlin from it.
To define the shape of a single variant value, @evanion/feature uses ValueShape, written as a JSON Schema 2020-12 object. The fence admits type, properties, required, items, enum, const, additionalProperties, $defs, $ref, title and description. A $ref resolves only to a $defs entry in the same document. Any other keyword reports unfenced-schema, including allOf, anyOf, oneOf and not. A remote $ref leaves a generator with a document it cannot resolve offline, and the four combinators produce Swift and Kotlin a reader cannot map back to the schema. An owner needing a union writes an enum over a discriminant.
The validateConfig function tests a key only where a shape declares a keyword. Because properties and $defs hold a map from a name their author chose to a shape, the walk descends into each value and tests no name; thus, a property called label and a definition called Label are treated as data. Similarly, items and additionalProperties hold one shape, so the walk descends into the value. Keywords like type, title, description, required, enum and const hold data and the walk stops at them; therefore, a variant value carrying the key oneOf under a const remains legal.
export type = <<string, unknown>>;The JSON a condition value may hold.
Full documentation
AttributeCondition.value is unknown, because a store built from a literal
holds whatever an author wrote. One of those values reaches the next process
as something else while canonical erases the difference: a Date, which
canonical writes as the ISO string JSON.parse hands back. The publisher
and the holder then digest alike over two values evaluateCondition compares
with ===, and § 2 reads two agreeing digests as a proof that two processes
hold one configuration. This type refuses a Date wherever it appears.
An object member written as undefined is legal, which § 8 states:
canonical.ts:46 filters the member out and JSON.stringify omits it, so an
absent key and a key written as undefined canonicalize to one text and
digest alike over bytes that agree. The member type admits undefined so
that { tier } read off a nullable column and { tier?: string } off a
generated type both reach this type, since canonical writes the two as the
same text. TypeScript strips undefined from an optional member when it
checks one against an index signature, so a fence here would admit the second
spelling and refuse the first.
An array element stays fenced to JsonValue. canonical writes an
undefined element as the text undefined and JSON.stringify writes it as
null, so the publisher and the holder compute two digests over one array
and the holder reports digest-mismatch.
The object arm is an index signature. TypeScript derives one implicitly for a
type alias and for an object literal, optional members included, and never
for an interface, so a value annotated with an interface does not reach
this type. § 4 declares every context field as a flat FieldType, so no
generator emits an interface for a condition value. A generated type lands at
SerializedVariantSpec.value, which carries unknown for that reason.
When an attribute condition compares against a value as a document carries it, @evanion/feature uses JsonValue. SerializedAttributeCondition.value is this type, and serializeConfig writes it.
In this type, an object member written as undefined is legal, but an array element is not. Because canonical filters an undefined member out and JSON.stringify omits it, an absent key and a key written as undefined digest alike. An undefined array element reaches no such agreement, since canonical writes it as the text undefined while JSON.stringify writes it as null, and the publisher and the holder would digest two different texts over one array. The serializeConfig function refuses that element and raises a FeatureConfigError naming the path, so no holder receives the document the two would disagree about.
The object arm is an index signature. Since TypeScript derives one for a type alias and for an object literal, but never for an interface, a value annotated with a generated interface does not reach this type. A variant value carries no such fence: SerializedVariantSpec.value is unknown, which allows a quicktype or openapi-generator interface to land there.
export type = string | number | boolean | null | readonly [] | {
readonly [: string]: | undefined;
};A definition as a document carries it.
Full documentation
F defaults to FeatureKey, the default FeatureConfig carries. This type
is the element type of FeatureConfig['features'], so a helper annotated
with the bare form reads a definition out of a bare document, and a
definition keyed on a numeric enum reaches both names.
To represent a feature in a static document, @evanion/feature uses the SerializedDefinition type. While a live store carries a FeatureDefinition, a document carries one SerializedDefinition per feature. This type is the element type of FeatureConfig['features'], and it is what serializeConfig writes.
The SerializedDefinition contains every member a live definition declares. The key, enabled, dependsOn, seed, freezeTimeAtBuild, variantBy, and variantSeed members carry the types a live definition declares. For a feature that declares variants and leaves the two bucketing members implicit, serializeConfig writes variantBy and variantSeed out. The rules member holds SerializedRule and the variants member holds SerializedVariantSpec.
Because a serialized definition is a definition a live store could hold, a holder can hand one from a parsed document to createFeatures untouched. A live definition does not reach this type, however, because a live condition admits a Date.
The F parameter defaults to FeatureKey (the default used by FeatureConfig), allowing a helper annotated with the bare name to read a definition out of a bare document.
interface < extends = > extends <<>, 'rules' | 'variants'> {
readonly ?: readonly SerializedRule[];
readonly ?: readonly SerializedVariantSpec[];
}
A rule as a document carries it, with its control-plane id untouched.
To define a rule within a document, @evanion/feature uses the SerializedRule type, where a live store would carry a Rule. The id provided by a control plane or author travels as written, and the rollout and variant members travel untouched. The when member holds SerializedCondition.
Every member a live rule declares is present here. Since when stays optional, a document can carry a rule that ramps a percentage and matches on nothing. The condition list is readonly; a holder that rewrote it would match differently from every other process holding the document this digest names.
A serialized rule is a rule a live store could hold. A live rule does not reach this type because its conditions admit a Date.
interface SerializedRule extends <Rule, 'when'> {
readonly ?: readonly [];
}
A condition as a document carries it. The instant and the value narrow.
To define a condition within a document, @evanion/feature uses the SerializedCondition type, where a live store would carry a Condition. This type is a union of three arms: SerializedWindowCondition, DayOfWeekCondition unchanged, and SerializedAttributeCondition.
A day-of-week condition needs no wire form of its own because its zone is a string and its value is an array of Weekday; JSON carries both as the store holds them. The other two arms each narrow the one member that admits a value JSON has no form for.
export type = SerializedWindowCondition | DayOfWeekCondition | SerializedAttributeCondition;A window condition as a document carries it.
To represent a window condition in a document, @evanion/feature uses the SerializedWindowCondition type, where a live store would carry a WindowCondition. In this type, the field is 'now', the op is 'before' or 'after', and value is a SerializedInstant.
A live window condition does not reach this type because Instant admits a Date. The two forms decide the window alike, since a window condition is read as a point in time and a string, a number, and a Date naming one instant all read to one epoch. The function serializeConfig converts the Date on the way out. Epoch milliseconds are a member of the type, which is what a producer with no ISO formatter writes.
interface SerializedWindowCondition {
: 'now';
: 'before' | 'after';
: ;
}
An attribute condition as a document carries it, compared with ===.
To represent an attribute condition in a document, @evanion/feature uses the SerializedAttributeCondition type, where a live store would carry an AttributeCondition. The field and the op travel as the store holds them, and value narrows from unknown to JsonValue.
A serialized attribute condition is an attribute condition a live store could hold. A live one does not reach this type because its value admits a Date.
This narrowing ensures that two processes that agree on a digest resolve alike. evaluateCondition compares this value with ===, and canonical writes a Date and the ISO string a transport hands the holder as one text. A publisher holding the Date and a holder holding the string therefore digest alike and compare apart, and § 2 of the distribution spec reads two agreeing digests as a proof that two processes hold one configuration. serializeConfig refuses the Date here and names the path.
The value is readonly. A holder that rewrote it would decide differently from the document every other process holds under the same digest.
interface SerializedAttributeCondition extends <AttributeCondition, 'value'> {
readonly : ;
}
A variant as a document carries it.
Full documentation
value stays unknown. § 4 picks JSON Schema for this member because
quicktype and openapi-generator emit TypeScript interfaces from it, and
TypeScript gives an interface no implicit index signature, so a JsonValue
here refuses the generated type the notation was picked to produce. § 8 puts
the round trip in serializeConfig, which converts a Date wherever it
appears, and § 7’s validator checks a variant value against the shape its
schema declares.
To represent a variant in a document, @evanion/feature uses the SerializedVariantSpec type, where a live store would carry a VariantSpec. The name and the weight travel as the store holds them, and serializeConfig writes an explicit order on every variant of a feature whose author left it implicit.
Unlike the attribute condition’s value, the value here stays unknown. § 4 of the distribution spec picks JSON Schema for this member because quicktype and openapi-generator emit TypeScript interfaces from it; since TypeScript gives an interface no implicit index signature, a JsonValue here would refuse the generated type the notation was picked to produce. A variant value typed by such an interface lands here and type-checks.
Because the type admits a value JSON cannot carry, serializeConfig is what refuses one. Its walk admits a string, a finite number, a boolean, null, an array, and a plain object, and it raises a FeatureConfigError naming the path for every other leaf, including a Date, a Map, a Set, and a RegExp. The value is readonly, because valueOf would answer one thing here and another in every process holding the document this digest names.
interface SerializedVariantSpec extends <VariantSpec, 'value'> {
readonly ?: unknown;
}
An Instant that survives JSON: an ISO 8601 string, or epoch milliseconds.
Full documentation
Instant at types.ts:16 admits a Date, which a literal in a TypeScript
file is what an author writes. JSON.stringify writes that Date as an ISO
string and JSON.parse hands back a string, so a document that made one hop
through a transport holds a different value than the one that authored it.
The two evaluate alike, because toEpoch in conditions.ts reads both to
one number, and configDigest over the two disagrees. A document narrows to
this, and serializeConfig converts on the way out.
To represent an instant in a document, @evanion/feature uses the SerializedInstant type, which allows an ISO 8601 string or epoch milliseconds, but never a Date. This type holds the rule defined in decision 12 of the distribution spec.
A Date is not a JSON value. Instant admits one (which is what an author writes in a TypeScript literal), and while JSON.stringify writes that Date as an ISO string, JSON.parse hands back a string. A holder reading a parsed document therefore holds a string; a document type that admitted a Date would promise the holder a value no parse produces.
A serialized instant is an instant a live store holds, so the type can annotate a live member too. The only member of a definition a Date reaches legally is WindowCondition.value, and serializeConfig handles the conversion: it writes that Date as its ISO string, raises a FeatureConfigError naming the path for a Date that names no instant, and refuses a Date at every other member of the document.
export type = string | number;Base class for every error this library throws. All of them are raised by
createFeatures; resolve, plan and toggle are total.
Full documentation
Constraints are enforced where the configuration is supplied rather than
where it is read, so a store a caller holds cannot fail mid-evaluation. It is
the same split @evanion/luhn and @evanion/token draw between
createLuhn/createToken and their operations.
To handle configuration defects in @evanion/feature, you can catch a single
base class. The FeatureConfigError class serves as the root for the other five
error types described on this page.
export declare class extends {
constructor(: string);
}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"'Using FeatureConfigError as a Catch-All
The createFeatures function raises FeatureConfigError for any defect the
checker reports that is not covered by the five specific subclasses. This
includes a wide range of configuration issues:
- A
whenproperty that the checker cannot read as an array of conditions. - A variant weight that is not a usable share.
- A feature that declares an order on none of its variants.
- A document that declares variants but carries no
variantByorvariantSeed. - A document carrying a top-level member that the holder cannot read.
- An inline schema missing a
schemaVersion. - A window boundary carrying
Number.NaN.
Because the error message names the specific feature and the member path, you
should read the message for debugging rather than switching on the class type. A
single catch block targeting this base class answers every defect the checker
reports.
How @evanion/feature handles copy errors
To ensure a configuration is safe to use, @evanion/feature validates the data
provided to createFeatures. Because VariantSpec.value is typed as unknown,
the checker reports nothing about a variant value. If a function or a symbol
is placed there, it clears every check, but is then refused by the copy
createFeatures takes before it installs the configuration.
The specific refusal the user receives depends on the host environment.
@evanion/feature utilizes globalThis.structuredClone where available, such
as in Node, a browser, or a worker, and falls back to its own JSON-shaped copy
walk on a host that defines no such global.
When structuredClone fails, it raises the host’s own DataCloneError (a
DOMException) naming the leaf. When the copy walk fails, it raises a
FeatureConfigError naming the member path, such as
"/variants/0/value" carries a function, and a document carries none.
Catching errors across different hosts
When operating over a served document, createFeatures rethrows either of the
above raises as a FeatureConfigError carrying the original text. This allows a
caller to use one catch block over the base class to handle errors on every
host; additionally, a holder catching FeatureConfigError to preserve an
existing document will not accidentally catch a DOMException.
If createFeatures is called over a bare array, the raise travels untouched. In
this scenario, the class the caller catches is the host’s native response rather
than the library’s. To resolve this, the caller must either read
error.message on the literal path or hand createFeatures a document and
catch the base class.
A dependency cycle. Resolution order is undefined for a cycle, so this is rejected rather than broken arbitrarily.
When a dependsOn chain closes on itself, the resolution order becomes
undefined. To prevent this, @evanion/feature refuses the set via a
FeatureCycleError.
The path property holds the closed walk and ends where it starts, ensuring the
closing edge is present both on the error object and in the error message.
export declare class extends {
readonly : readonly [];
constructor(: readonly []);
}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"'A dependsOn naming a feature that is not in the configuration.
If a dependsOn property names a feature that no definition in the set
declares, @evanion/feature raises an UnknownDependencyError. The key
property identifies the feature that named the missing dependency, and
dependency identifies the name that resolves to nothing.
export declare class extends {
readonly : ;
readonly : ;
constructor(: , : );
}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"'Two definitions with the same key.
When two definitions carry the same key, @evanion/feature raises a
DuplicateFeatureError carrying that key. This typically occurs when a document
is assembled from two different sources; the key named in the error is the one
to investigate in both sources.
export declare class extends {
readonly : ;
constructor(: );
}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"'Two variants of one feature share a name, so a pin names both.
If two variants of one feature declare the same name, a rule pinning that name
names both, so @evanion/feature refuses the feature via a
DuplicateVariantError.
The feature and variant properties carry the conflicting pair.
export declare class extends {
readonly : string;
readonly : string;
constructor(: string, : string);
}import { , } from '@evanion/feature';
import type { } from '@evanion/feature';
const = (: []) => {
try {
();
return 'built';
} catch () {
return instanceof ? . : 'built';
}
};
const : [] = [
{
: 'checkout-cta',
: true,
: [
{ : 'control', : 50 },
{ : 'control', : 50 },
],
},
];
const : [] = [
{
: 'checkout-cta',
: true,
: [{ : 'control', : 100 }],
: [{ : 'booksellers', : 'blue' }],
},
];
(); // -> 'DuplicateVariantError'
(); // -> 'UnknownVariantError'A rule pins a variant its feature does not declare.
When a rule’s variant names a variant that its feature does not declare, the
rule would match and pin nothing, and variantOf would answer with a name the
schema does not carry. In this case, @evanion/feature raises an
UnknownVariantError.
The feature and variant properties carry the pair. This error is also raised
if a feature declares no variants at all but carries a rule with a variant.
export declare class extends {
readonly : string;
readonly : string;
constructor(: string, : string);
}import { , } from '@evanion/feature';
import type { } from '@evanion/feature';
const = (: []) => {
try {
();
return 'built';
} catch () {
return instanceof ? . : 'built';
}
};
const : [] = [
{
: 'checkout-cta',
: true,
: [
{ : 'control', : 50 },
{ : 'control', : 50 },
],
},
];
const : [] = [
{
: 'checkout-cta',
: true,
: [{ : 'control', : 100 }],
: [{ : 'booksellers', : 'blue' }],
},
];
(); // -> 'DuplicateVariantError'
(); // -> 'UnknownVariantError'