@evanion/feature
Not on npm yet
npm install @evanion/feature does not resolve. The package is private: true, so a release run versions and tags it without publishing: npm cannot configure a trusted publisher for a package that does not exist on the registry, and the first version has to go up by hand.
The implementation is complete and covered by 83 passing tests, against the
approved spec at docs/specs/2026-09-11-feature-toggles.md. Inside this
repository it resolves from source like any other workspace package.
Feature toggles where one flag can depend on another. A parent that resolves off takes its dependants with it, transitively, and it does that without writing anything back into the configuration.
import { } from '@evanion/feature';
const = ([
{
: 'new-checkout',
: true,
: [
{
: 'after-launch',
: [{ : 'now', : 'after', : '2026-10-01T00:00:00Z' }],
: { : 25, : 'targetingKey' },
},
],
},
{ : 'express-pickup', : true, : ['new-checkout'] },
]);
const = new ('2026-11-01T00:00:00Z');
.('express-pickup', { : 'cust-0042', }); // -> true
.('express-pickup', { : 'cust-0107', }); // -> falseExpress pickup is offered only to a customer the new checkout is already on
for. cust-0042 and cust-0107 differ because the 25% rollout put them in
different buckets. Without the cascade, a quarter of customers would get the
new checkout and all of them would get its pickup option.
Two entry points
import { createFeatures } from '@evanion/feature';
import { FeatureProvider, useFeature } from '@evanion/feature/react';The core imports no framework, so it runs in an API, in a script, or at build time. The React adapter is a provider and three hooks over an already-built store, and is client code. Nothing in it decides anything.
'use client' is a per-module directive and a bundle is one module, so the
package is emitted file by file and the core entry stays free of both the
directive and any React import.
The shape of a decision
resolve returns one decision per feature. enabled is the answer; everything
else explains it. Here the new checkout is on for booksellers first, so a
customer’s express pickup is off with the parent named:
import { } from '@evanion/feature';
const = ([
{
: 'new-checkout',
: true,
: [
{
: 'staff-first',
: [{ : 'role', : 'eq', : 'bookseller' }],
},
],
},
{ : 'express-pickup', : true, : ['new-checkout'] },
]);
.({ : 'customer' })['express-pickup']; // -> { key: 'express-pickup', enabled: false, reason: 'dependency-off', blockedBy: 'new-checkout', cause: { key: 'new-checkout', reason: 'no-rule-matched', rule: 'staff-first' } }reason is output only. Nothing in this library reads it back to decide
anything, so deleting it would change no decision. It exists for the operator
looking at a flag that is off and asking why.
The store never writes
The only writers are toggle() and editing the configuration. resolve() and
plan() are pure functions of (config, context, now).
When a parent’s time window expires, nothing changes in the store; resolve()
starts returning false for the dependant. When the window reopens it returns
true again. The dependant’s enabled was true throughout, because that field
means “the maintainer wants this on”, which stayed true.
Writing resolved values back would put entries in an audit log that nobody performed, make a process that slept through a window boundary disagree with one that was awake, and erase the difference between “someone turned this off” and “the system turned it off”.
Pages
- Configuration — definitions, precedence, conditions
- Rollouts — bucketing, seeds, and why raising a percentage is additive
- Decisions — resolving, toggling, and reading a reason
- Build-time planning —
plan()for static sites - React — the provider and the three hooks
- API reference