@studnicky/flag-evaluator
Local deterministic feature-flag evaluation with percentage rollout and observability hooks.
Install
pnpm add @studnicky/flag-evaluatorUsage
Register named boolean flag definitions (enabled, optional rolloutPercent, defaultValue) and resolve each evaluate() call deterministically via @studnicky/json's Hash — the same flag and targeting key always land in the same rollout bucket:
import { FlagEvaluator } from '../src/index.js';
class TelemetryFlagEvaluator extends FlagEvaluator {
static override create(): TelemetryFlagEvaluator {
return new TelemetryFlagEvaluator();
}
readonly evaluations: { 'flag': string; 'result': boolean }[] = [];
readonly defaults: string[] = [];
readonly ruleMismatches: string[] = [];
protected override onEvaluate(flag: string, _context: Record<string, unknown>, result: boolean): void {
console.log(`[flags] '${flag}' -> ${String(result)}`);
this.evaluations.push({ 'flag': flag, 'result': result });
}
protected override onDefault(flag: string): void {
console.log(`[flags] '${flag}' is not registered — returning false`);
this.defaults.push(flag);
}
protected override onRuleMismatch(flag: string, context: Record<string, unknown>): void {
console.log(`[flags] '${flag}' rollout bucket missed for`, context);
this.ruleMismatches.push(flag);
}
}
const evaluator = TelemetryFlagEvaluator.create();
evaluator.register('dark-mode', { 'defaultValue': false, 'enabled': true });
evaluator.register('legacy-search', { 'defaultValue': false, 'enabled': false });
evaluator.register('new-checkout', { 'defaultValue': false, 'enabled': true, 'rolloutPercent': 25 });
const darkModeOn = evaluator.evaluate('dark-mode', { 'targetingKey': 'user-42' });
const legacySearchOn = evaluator.evaluate('legacy-search', { 'targetingKey': 'user-42' });
const unregisteredOn = evaluator.evaluate('never-registered', { 'targetingKey': 'user-42' });
// the same targetingKey always resolves the same way for the same flag
const checkoutFirst = evaluator.evaluate('new-checkout', { 'targetingKey': 'user-42' });
const checkoutSecond = evaluator.evaluate('new-checkout', { 'targetingKey': 'user-42' });Unregistered vs. disabled
- Unregistered:
evaluate()returnsfalseunconditionally and firesonDefault(name). There is no per-call default-value override. - Registered,
enabled: false:evaluate()returns the registereddefinition.defaultValue. - Registered,
enabled: true:evaluate()bucketscontext.targetingKeydeterministically into[0, 100)— hashingname + ':' + targetingKeyviaHash.value(), parsing the resulting hex digest as a base-16 integer, and reducing it% 100— and returnsbucket < (definition.rolloutPercent ?? 100).
| Method | Description |
|---|---|
FlagEvaluator.create() | Creates an empty evaluator |
register(name, definition) | Registers (or replaces) a named flag: { enabled, rolloutPercent?, defaultValue } |
unregister(name) | Removes a named flag; no-op if it was never registered |
has(name) | Whether a flag is currently registered under name |
list() | The names of every currently registered flag |
evaluate(name, context) | Resolves a boolean decision for name given context: { targetingKey?, ...arbitrary } |
Hooks
| Hook | Fires |
|---|---|
onEvaluate(flag, context, result) | After every resolution, on every path, as the last thing before evaluate() returns |
onDefault(flag) | When evaluate() is called for a flag name that was never registered |
onRuleMismatch(flag, context) | When an enabled flag's rollout bucket falls outside the enabled range (rollout exclusion only — never fires for a disabled flag) |
Scope
FlagEvaluator owns only local, in-process flag resolution — no HTTP client, no background polling, no vendor SDK. This is the exact "local flag evaluation" core that OpenFeature's spec separates from its remote Provider; fetching flag definitions from a remote source and calling register() with the results is entirely a consuming application's concern.
Documentation
Full reference: https://studnicky.github.io/substrate/packages/flag-evaluator