Skip to Content
FeatureBuild-time Planning

Build-time planning

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.

plan() partitions every feature into resolvable now and deferred, for a build that does not have the whole evaluation context.

import { } from '@evanion/feature'; const = ([ { : 'new-checkout', : true, : [{ : 'a-share', : { : 10, : 'targetingKey' } }], }, ]); .({ : new ('2026-11-01T00:00:00Z') }); // -> { 'new-checkout': { key: 'new-checkout', resolved: 'deferred', needs: ['targetingKey'] } }

The new checkout is deferred because its rollout reads targetingKey, which a build does not have. A static site emits only the deferred set and resolves that per request; an API calls resolve() per request. The resolvable cases go through the same decide as resolve does.

A plan entry

interface PlanEntry<F> { key: F; resolved: boolean | 'deferred'; needs: readonly string[]; // sorted; empty unless deferred decision?: Decision<F>; // present when `resolved` is a boolean }

resolved: true or false is a settled answer, and decision carries it with its reason. resolved: 'deferred' means some rule still needs context this plan did not have, and needs lists the fields, sorted.

A deferred feature’s needs includes whatever its deferred parents need, so an entry’s list is everything the request has to supply before this feature can be decided at all.

What makes a rule resolvable

A rule whose fields are all present in the context can be decided now; one that still needs a field cannot.

features.plan({ targetingKey: 'cust-0101' }); // new-checkout: { resolved: 'deferred', needs: ['now'] }

The rollout resolved — targetingKey was there — and the time window did not.

createFeatures([ { key: 'staff-only', enabled: true, rules: [ { id: 'staff-first', when: [{ field: 'role', op: 'eq', value: 'bookseller' }], }, ], }, ]).plan({}); // { 'staff-only': { key: 'staff-only', resolved: 'deferred', needs: ['role'] } }

A feature with enabled: false is resolved: false immediately, whatever its rules need. A feature with no rules is resolved: true, given enabled.

now is deferred unless the feature opts in

now is deliberately not treated as available, even though plan() fills it in the same way resolve() does:

createFeatures([ { key: 'x', enabled: true, freezeTimeAtBuild: true, rules: [ { id: 'live', when: [{ field: 'now', op: 'after', value: '2026-01-01T00:00:00Z' }], }, ], }, { key: 'y', enabled: true, dependsOn: ['x'] }, ]).plan({ now: new Date('2026-11-01T00:00:00Z') }); // x: { resolved: true, needs: [], decision: { enabled: true, reason: 'rule-match', rule: 'live' } } // y: { resolved: true, needs: [], decision: { enabled: true, reason: 'default-on' } }

Whether a date window may be frozen into a build is a deploy-cadence decision, and it belongs to whoever owns the feature rather than to whoever runs the build. A site that rebuilds hourly can freeze a window that opens on a date; a site that rebuilds monthly cannot, and the same flag in both places has to be able to say so.

Without freezeTimeAtBuild, a now-dependent rule is deferred and 'now' appears in needs. The static build emits the rule and the request resolves it.

Using a plan

The two halves go to different places.

plan() splits every feature in two. The settled half ships with the build as a decisions record; the deferred half ships as rules the request resolves, and the union of their needs is the context the request has to assemble.
const plan = features.plan({ now: new Date() }); const settled = Object.fromEntries( Object.entries(plan) .filter(([, entry]) => entry.resolved !== 'deferred') .map(([key, entry]) => [key, entry.decision!]), ); const deferred = Object.values(plan) .filter((entry) => entry.resolved === 'deferred') .map((entry) => entry.key);

settled is a Decisions record, which is exactly what <FeatureProvider decisions={…}> takes — see React. Ship it with the build. deferred is the list the runtime still has to resolve, and the union of their needs is the context the runtime has to assemble.

A flag that is settled false at build time can be stripped from the bundle entirely. One that is deferred cannot, because the branch it gates has to exist for the request that turns it on.

Last updated on