Skip to Content
FeatureOverview

@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', }); // -> false

Express 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

Last updated on