Percentage Rollout Feature Flag Evaluator

Enable features for a stable percentage of users by hashing flag key and user ID into buckets, with allowlist overrides.

TypescriptConfig Management

Code

snippet
export interface FlagContext {
  readonly userId: string;
  readonly allowlist?: ReadonlySet<string>;
}

export interface FlagDecision {
  readonly enabled: boolean;
  readonly bucket: number;
  readonly reason: "allowlist" | "percentage" | "off";
}

export function evaluateFlag(flagKey: string, percentage: number, context: FlagContext): FlagDecision {
  if (typeof flagKey !== "string" || flagKey.trim() === "") {
    throw new TypeError("flagKey must be a non-empty string");
  }
  if (typeof context.userId !== "string" || context.userId === "") {
    throw new TypeError("userId must be a non-empty string");
  }
  if (!Number.isFinite(percentage) || percentage < 0 || percentage > 100) {
    throw new RangeError("percentage must be between 0 and 100");
  }
  if (context.allowlist?.has(context.userId) === true) {
    return { enabled: true, bucket: 0, reason: "allowlist" };
  }
  if (percentage === 0) return { enabled: false, bucket: 100, reason: "off" };
  const bucket = bucketFor(`${flagKey}:${context.userId}`) / 100;
  return { enabled: bucket < percentage, bucket, reason: "percentage" };
}

function bucketFor(value: string): number {
  let hash = 0x811c9dc5;
  for (let index = 0; index < value.length; index += 1) {
    hash ^= value.charCodeAt(index);
    hash = Math.imul(hash, 0x01000193);
  }
  return (hash >>> 0) % 10000;
}

const allowlist = new Set(["internal-1"]);
console.log(evaluateFlag("new-checkout", 25, { userId: "user-42", allowlist }));
console.log(evaluateFlag("new-checkout", 25, { userId: "internal-1", allowlist }));
console.log(evaluateFlag("new-checkout", 0, { userId: "user-42" }));