Skip to content

@studnicky/flag-evaluator

Local deterministic feature-flag evaluation with percentage rollout and observability hooks.

Install

bash
pnpm add @studnicky/flag-evaluator

Usage

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:

ts
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() returns false unconditionally and fires onDefault(name). There is no per-call default-value override.
  • Registered, enabled: false: evaluate() returns the registered definition.defaultValue.
  • Registered, enabled: true: evaluate() buckets context.targetingKey deterministically into [0, 100) — hashing name + ':' + targetingKey via Hash.value(), parsing the resulting hex digest as a base-16 integer, and reducing it % 100 — and returns bucket < (definition.rolloutPercent ?? 100).
MethodDescription
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

HookFires
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

Source on GitHub