DEV Community

Cover image for A type-safe decision engine in ReScript
Piyush Chauhan
Piyush Chauhan

Posted on AI-assisted

A type-safe decision engine in ReScript

How a ReScript + Next.js demo runs experiments and rules through one validated registry, with deterministic bucketing and provenance on every decision

Experiments, rules, and preview overrides through one validated registry, with provenance on every value.

TL;DR: every variant question becomes one typed decision computed on the server, and every value remembers which layer wrote it. Two experiments cannot write the same field without failing loudly. The engine lives in the Elsewhere demo, a hotel site built with ReScript and Next.js.

Why stringly-typed flags keep breaking

A flag pipeline starts cheap. Someone puts hero_layout = split in a config table, reads it inside a component, and branches on the string. Nothing connects the read to the write: no compiler knows that splti is a typo, and no test knows which component owns the field.

Then the experiments multiply. Two of them write the same field, one silently wins, and the bug report says the hero layout flips between requests. Six months later nobody can answer why a particular visitor saw compact, because the value is in a payload and the reason is nowhere. One registry plus one resolve step fixes both problems, and that is the rest of this post.

The demo: Elsewhere, a hotel site with experiments baked in

I built the demo to test one architecture end to end. It is a hotel and destinations site that renders on the server, reads a SQLite catalog, and ships a development-only inspector. Everything runs from the Elsewhere repository.

The code is split three ways, and the dependency direction is the point:

Three workspaces: apps/web and packages/ui both import packages/core, which depends only on the ReScript runtime

The engine owns seven decision paths. Nothing else is adjustable, by design:

Path Type What it changes
hero.layout immersive / split hero image treatment
search.layout overlay / inline destination search placement
destinationCard.layout image / compact which card component renders
destinations.columns three / two destination grid density
offers.visible bool seasonal offers section
planningGuide.visible bool "PLAN AHEAD" prompts
planningGuide.detail brief / expanded prompt wording

Those seven fields are the only surface any experiment or rule may touch, which is what keeps ownership checkable.

The request path, end to end

A request crosses four layers before any HTML exists. The proxy handles identity, the page route validates and calls the engine, the engine returns a snapshot, and the ReScript React shell renders from that snapshot without re-deriving anything.

sequenceDiagram
    participant B as Browser
    participant P as Next proxy (middleware)
    participant R as Page route
    participant C as @repo/core (decision engine)
    participant U as @repo/ui (ReScript React)
    B->>P: GET / (no visitorId)
    P->>P: verify or issue signed cookie
    P-->>B: Set-Cookie: visitorId=<uuid>.<hmac>
    B->>R: GET / (with cookie)
    R->>C: resolveDecisionRequest(page, search, visitorId, rollout)
    C->>C: validate search, build context, allocate, resolve
    C-->>R: decisionSnapshot {values, provenance, assignments, ignored}
    R-->>U: snapshot + catalog + search
    U-->>B: HTML (SSR)

Decisions are types, not strings

Every decision value is a variant. heroLayout is #immersive or #split, destinationCardLayout is #image or #compact, and a renderer that handles one case but not the other fails compilation. That property is why the engine is written in ReScript rather than TypeScript.

Strings still exist, but only at the wire boundary. @as("hero.layout") keeps the JSON key dotted while the value stays a variant, and @tag("source") labels every provenance case with the layer that produced it.

@genType type page = [#home | #destinations]
@genType type variant = [#control | #treatment]
@genType type country = [#IN | #US]
@genType type heroLayout = [#immersive | #split]
@genType type searchLayout = [#overlay | #inline]
@genType type cardLayout = [#image | #compact]
@genType type columns = [#three | #two]
@genType type guideDetail = [#brief | #expanded]

@genType @unboxed
type path =
  | @as("hero.layout") HeroLayout
  | @as("search.layout") SearchLayout
  | @as("destinationCard.layout") DestinationCardLayout
  | @as("destinations.columns") DestinationsColumns
  | @as("offers.visible") OffersVisible
  | @as("planningGuide.visible") PlanningGuideVisible
  | @as("planningGuide.detail") PlanningGuideDetail

@genType
type decisionValues = {
  @as("hero.layout") mutable heroLayout: heroLayout,
  @as("search.layout") mutable searchLayout: searchLayout,
  @as("destinationCard.layout") mutable destinationCardLayout: cardLayout,
  @as("destinations.columns") mutable destinationsColumns: columns,
  @as("offers.visible") mutable offersVisible: bool,
  @as("planningGuide.visible") mutable planningGuideVisible: bool,
  @as("planningGuide.detail") mutable planningGuideDetail: guideDetail,
}
// Sparse fields are read through the validated string-keyed boundary codec.
@genType
type decisionPatch = {
  @live @as("hero.layout") heroLayout?: heroLayout,
  @live @as("search.layout") searchLayout?: searchLayout,
  @live @as("destinationCard.layout") destinationCardLayout?: cardLayout,
  @live @as("destinations.columns") destinationsColumns?: columns,
  @live @as("offers.visible") offersVisible?: bool,
  @live @as("planningGuide.visible") planningGuideVisible?: bool,
  @live @as("planningGuide.detail") planningGuideDetail?: guideDetail,
}
@genType @tag("source")
type provenance =
  | @as("default") Default({})
  | @as("rule") Rule({id: string})
  | @as("experiment") Experiment({id: string, variant: variant})
Enter fullscreen mode Exit fullscreen mode

The codec helpers below that block (cast, dictionary, objectValue, fail, defaults, validPatchValue, and decodePatch) validate the wire boundary, rejecting unknown paths and out-of-range values before any write happens.

One registry owns every experiment and rule

Experiments and rules are declared as data, and createDecisionRegistry validates the whole declaration at module load. Duplicate IDs, overlapping allocations, and two owners claiming the same path all throw at startup, so a broken registry never serves a request.

@genType
let experiments: array<experiment> = [
  {
    id: "arrival-flow",
    surface: "arrival-flow",
    enabled: true,
    allocation: (0, 10000),
    owns: [HeroLayout, SearchLayout, DestinationCardLayout],
    variants: {
      control: {},
      treatment: {heroLayout: #split, searchLayout: #inline, destinationCardLayout: #compact},
    },
  },
  {
    id: "destination-density",
    surface: "destination-density",
    enabled: true,
    allocation: (0, 10000),
    owns: [DestinationsColumns],
    variants: {control: {}, treatment: {destinationsColumns: #two}},
  },
  {
    id: "planning-guide-detail",
    surface: "planning-guide-detail",
    enabled: true,
    allocation: (0, 10000),
    owns: [PlanningGuideDetail],
    variants: {control: {}, treatment: {planningGuideDetail: #expanded}},
  },
]
Enter fullscreen mode Exit fullscreen mode

Ownership is the strictest of those checks, because it is the one that catches a logic mistake rather than a typo:

      let conflict = switch previous {
      | Some(previous) => surface == None || previous.surface != surface
      | None => false
      }
      if own(local, name) || conflict {
        let previousOwner = switch previous {
        | Some(p) => p.owner
        | None => owner
        }
        fail("Ownership conflict on " ++ name ++ ": " ++ previousOwner ++ " and " ++ owner)
Enter fullscreen mode Exit fullscreen mode

That gives a registry with five members:

Kind id owns effect
experiment arrival-flow hero.layout, search.layout, destinationCard.layout treatment sets split / inline / compact
experiment destination-density destinations.columns treatment sets two
experiment planning-guide-detail planningGuide.detail treatment sets expanded
rule india-seasonal-offers offers.visible when seasonal-offers flag AND country IN, sets offers.visible = true
rule planning-guide-flag planningGuide.visible when planning-guide flag, sets planningGuide.visible = true

Allocation is (0, 10000) on each surface, which is the full slot range: one experiment owns the whole surface and every visitor lands on one of its two variants.

Deterministic assignment without server state

Assignment is a pure function of the visitor ID plus a namespace string. FNV-1a hashes the concatenation, and the result is reduced twice: modulo 100 picks the 50/50 variant, modulo 10000 picks the slot inside a surface. There is no assignment table, no database write, and no session store.

That makes assignment replayable. The same cookie and the same experiment ID produce the same variant on a cold server, after a restart, or behind a different edge node, which is the whole reason the engine can run without shared state.

/** FNV-1a hashes JavaScript UTF-16 code units, including surrogate pairs. */
@genType let fnv1a32 = (input: string): float => {
  let hash = ref(-2128831035)
  for index in 0 to length(input) - 1 {
    hash.contents = imul(Int.bitwiseXor(hash.contents, charCodeAt(input, index)), 16777619)
  }
  unsigned(hash.contents)
}
@genType
let bucketForExperiment = (visitorId: string, experimentId: string): int =>
  Int.fromFloat(fnv1a32(visitorId ++ ":" ++ experimentId) % 100.)
@genType
let bucketForSurface = (visitorId: string, surface: string): int =>
  Int.fromFloat(fnv1a32(visitorId ++ ":surface:" ++ surface) % 10000.)
@genType
let assignVariant = (visitorId: string, experimentId: string): DecisionTypes.variant =>
  if bucketForExperiment(visitorId, experimentId) < 50 {
    #control
  } else {
    #treatment
  }
Enter fullscreen mode Exit fullscreen mode

The four externals and the unsigned helper I left out of that snippet bind Math.imul, charCodeAt, length, and the >>> 0 unsigned coercion.

Both reductions are visible in the geometry of one experiment:

The 0 to 10000 slot axis for the arrival-flow surface, and the mod 100 split into control and treatment

Slots live in a sorted table per surface, so picking one is a binary search:

let findSlot = (table: array<Experiments.experiment>, bucket) => {
  let low = ref(0)
  let high = ref(Belt.Array.length(table))
  while low.contents < high.contents {
    let middle = (low.contents + high.contents) / 2
    let (start, _) = Belt.Array.getUnsafe(table, middle).allocation
    if start <= bucket {
      low.contents = middle + 1
    } else {
      high.contents = middle
    }
  }
  if low.contents == 0 {
    None
  } else {
    let candidate = Belt.Array.getUnsafe(table, low.contents - 1)
    let (_, end) = candidate.allocation
    if bucket < end {
      Some(candidate)
    } else {
      None
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The whole path from cookie to variant is one hash, one modulo, and one comparison:

flowchart LR
    A["visitorId + ':surface:' + surface"] --> B[FNV-1a 32-bit hash]
    B --> C["mod 10000 → bucket 0..9999"]
    C --> D[Binary search sorted allocation table]
    D --> E[Experiment owns that slot]
    E --> F["bucket % 100 < 50 → control / treatment"]

Rules: eligibility and effects

Experiments and rules both end in a patch, but they get there differently. An experiment is picked by slot and then filtered by eligibleWhen, so a visitor can be in a slot and still hold out. A rule has no allocation at all: its when clause is the entire gate, and it applies whenever that clause is true.

@genType @tag("type")
type rec rule =
  | @as("all") All({rules: array<rule>})
  | @as("any") Any({rules: array<rule>})
  | @as("not") Not({rule: rule})
  | @as("eq") Eq({@live field: string, value: country})
  | @as("flag") Flag({name: string})
Enter fullscreen mode Exit fullscreen mode

Predicates are data too, and the demo ships two of them:

@genType
let ruleEffects: array<ruleEffect> = [
  {
    id: "india-seasonal-offers",
    owns: [OffersVisible],
    when_: All({
      rules: [Flag({name: "seasonal-offers"}), Eq({field: "visitor.country", value: #IN})],
    }),
    patch: {offersVisible: true},
  },
  {
    id: "planning-guide-flag",
    owns: [PlanningGuideVisible],
    when_: Flag({name: "planning-guide"}),
    patch: {planningGuideVisible: true},
  },
]
Enter fullscreen mode Exit fullscreen mode

decodeRule validates the JSON shape of a predicate and rejects unknown fields, flags, and rule nodes, so evaluateValidatedRule can walk the tree without re-checking anything.

Resolution and provenance

Resolution applies patches in a fixed order: the selected experiments first, then the rules. A second write to the same path is a failure, not an overwrite, and every write records its source. That pairing is what makes the inspector honest, because a value and its reason are produced by the same line of code.

  let apply = (patch: decisionPatch, owner, source) => {
    // Registry codecs have validated these wire keys and values already.
    let entries: array<(string, unknown)> = Dict.toArray(cast(patch))
    entries->Belt.Array.forEach(((name, value)) => {
      switch getOwn(writtenBy, name) {
      | Some(previous) =>
        fail("Decision write conflict on " ++ name ++ ": " ++ previous ++ " and " ++ owner)
      | None => ()
      }
      Dict.set(writtenBy, name, owner)
      switch decodePath(cast(name)) {
      | HeroLayout =>
        values.heroLayout = cast(value)
        provenance.heroLayout = source
      | SearchLayout =>
        values.searchLayout = cast(value)
        provenance.searchLayout = source
      | DestinationCardLayout =>
        values.destinationCardLayout = cast(value)
        provenance.destinationCardLayout = source
      | DestinationsColumns =>
        values.destinationsColumns = cast(value)
        provenance.destinationsColumns = source
      | OffersVisible =>
        values.offersVisible = cast(value)
        provenance.offersVisible = source
      | PlanningGuideVisible =>
        values.planningGuideVisible = cast(value)
        provenance.planningGuideVisible = source
      | PlanningGuideDetail =>
        values.planningGuideDetail = cast(value)
        provenance.planningGuideDetail = source
      }
    })
  }
Enter fullscreen mode Exit fullscreen mode

A snapshot for one visitor (country IN, offers=on, arrival-flow treatment) carries a badge per value:

A decision snapshot: seven paths with values, and badges marking default, rule, and experiment writes

The order of operations, from query string to provenance:

flowchart LR
    A[Search params] --> B[validatePageSearch allowlist]
    B --> C[Rule context: country + flags]
    C --> D[Allocate: one experiment per surface]
    D --> E[Resolve: apply experiment then rule patches]
    E --> F[decisionSnapshot]
    F --> G["provenance: default | rule | experiment"]

Preview overrides and the decision inspector

In development, query params force a variant: ?exp.arrival-flow=treatment. The allowlist decides what survives the trip. An unknown key is dropped, and a recognized key whose value is malformed stays in the snapshot as a diagnostic instead of disappearing.

let recognized = key =>
  key == "country" ||
  key == "offers" ||
  key == "guide" ||
  (String.startsWith(key, "exp.") &&
  own(Experiments.decisionRegistry.experimentById, String.slice(key, ~start=4)))

@genType
let validatePageSearch = (input: unknown, production: bool): pageSearch => {
  let result = dictionary()
  switch JSON.Decode.object(cast(input)) {
  | None => ()
  | Some(source) =>
    Dict.toArray(source)->Belt.Array.forEach(((key, value)) => {
      if (
        (key == "destination" || (!production && recognized(key))) && Type.typeof(value) == #string
      ) {
        Dict.set(result, key, (cast(value): string))
      }
    })
  }
  result
}
Enter fullscreen mode Exit fullscreen mode

The decision inspector is a development-only panel that prints every resolved value next to its provenance, lists ignored overrides with their raw values, and lets you flip country, flags, and individual experiments to re-render the page from a new snapshot.

Signed cookies and rollout flags

Identity is a cookie. In production that cookie is a UUID plus an HMAC-SHA-256 signature: the edge proxy verifies it or issues a new one, and the page verifies it again before the engine sees it. A missing, tampered, or unsigned cookie is treated as no identity at all.

function signature(id: string, secret: string): string {
  return createHmac("sha256", secret).update(id).digest("hex");
}
export function visitorFromCookie(
  cookie: string | undefined,
  secret: string | undefined,
): string | undefined {
  if (!secret) {
    return cookie && uuid.test(cookie) ? cookie : undefined;
  }
  if (!cookie) {
    return undefined;
  }
  const match = /^([0-9a-f-]{36})\.([0-9a-f]{64})$/.exec(cookie);
  if (!match || !uuid.test(match[1])) {
    return undefined;
  }
  return timingSafeEqual(
    Buffer.from(match[2], "hex"),
    Buffer.from(signature(match[1], secret), "hex"),
  )
    ? match[1]
    : undefined;
}
export function visitorCookie(id: string, secret: string | undefined): string {
  return secret ? id + "." + signature(id, secret) : id;
}
Enter fullscreen mode Exit fullscreen mode

I left out the uuid regex constant above those functions, which checks the UUID shape before any signature comparison happens.

Rollout configuration is validated the same way, eagerly, at startup. Flag values accept only on, empty, or absent, and anything else throws before the server listens:

function flag(name: string): boolean {
  const value = process.env[name];
  if (value !== undefined && value !== "" && value !== "on") {
    throw new Error(`Invalid ${name}: expected on or empty`);
  }
  return value === "on";
}
Enter fullscreen mode Exit fullscreen mode

The disabled-experiment list and the production cookie secret get the same treatment, both inside readRollout:

export function readRollout(): rollout & { cookieSecret: string | undefined } {
  const known = new Set(experiments.map((experiment) => experiment.id));
  const disabledIds = [
    ...new Set(
      (process.env.DECISION_DISABLED_EXPERIMENTS ?? "")
        .split(",")
        .map((v) => v.trim())
        .filter(Boolean),
    ),
  ];
  for (const id of disabledIds) {
    if (!known.has(id)) {
      throw new Error(`Invalid DECISION_DISABLED_EXPERIMENTS: unknown experiment ${id}`);
    }
  }
  let cookieSecret: string | undefined;
  if (process.env.NODE_ENV === "production") {
    cookieSecret = process.env.DECISION_COOKIE_SECRET;
    if (!cookieSecret || Buffer.byteLength(cookieSecret, "utf8") < 32) {
      throw new Error("Invalid DECISION_COOKIE_SECRET: at least 32 UTF-8 bytes required");
    }
  }
Enter fullscreen mode Exit fullscreen mode

seasonalOffers and planningGuide are read with the same flag() call, so both accept only absent, empty, or on, and off fails startup.

Crossing the ReScript ↔ TypeScript boundary

genType emits a .gen.tsx and a .res.mjs per module, which is how the RegistryCore and RegistryUi namespaces become importable TypeScript. @live, @as, and @tag carry the wire contract across, so the TS side sees the same strings the JSON has.

The React shell needs one context and one hook, and that is the entire UI-side integration:

type values = RegistryCore.DecisionTypes.decisionValues
let context: React.Context.t<option<values>> = React.createContext(None)

module Provider = {
  let make = React.Context.provider(context)
}

@react.component
let make = (~values: values, ~children) => <Provider value={Some(values)}> {children} </Provider>

let useDecision = (selector: values => 'a): 'a => {
  switch React.useContext(context) {
  | Some(values) => selector(values)
  | None => JsError.throwWithMessage("useDecision requires a DecisionProvider")
  }
}
Enter fullscreen mode Exit fullscreen mode

A page component then reads a single field through that hook, and the field name is checked by the ReScript compiler:

    let heroLayout = DecisionProvider.useDecision(values => values.heroLayout)
Enter fullscreen mode Exit fullscreen mode

One more adaptation lives in apps/web/src/server/page-data.ts, where resolved, assignments, and ignored are spread into plain objects before they cross into React Flight, because null-prototype dictionaries are not accepted there.

What this cost us

Registering an experiment is ceremony. Every new one needs an id, a surface, an allocation, an owns list, and both variants declared before anything renders. The registry catches the mistakes at startup, but I still wrote that record by hand each time.

ReScript is a second compiler and a second syntax. The OCaml heritage shows up in ref, Belt.Array, and pipe-first argument order, and the exhaustiveness that makes the engine safe also means every new path case touches every consumer.

The wire still speaks strings. hero.layout appears as a string in the JSON, in the serialized snapshot, and in the generated TypeScript types. The variants only exist after validation, which is a real cost in a codebase that is mostly TypeScript.

Identity is one sticky cookie. Anyone who clears cookies or switches devices is a new visitor to the engine, and there is no cross-device merge.

Nothing tracks exposure or conversions. The inspector panel states that in one line, and it is accurate: the engine assigns deterministically and reports provenance, and analytics are out of scope for the demo.

Run it yourself

Node >= 22.12 and pnpm 12.4.2 are the only prerequisites.

pnpm install
pnpm exec playwright install chromium
export DB_FILE_NAME=/absolute/path/to/catalog.db
pnpm db:setup
pnpm dev
Enter fullscreen mode Exit fullscreen mode

Open http://127.0.0.1:3000, then try /?exp.arrival-flow=treatment&country=IN&offers=on to see a forced variant, a forced country, and the India offers rule land in the same snapshot. All of it is in the Elsewhere repository.

FAQ

Why ReScript instead of TypeScript?

Exhaustive matching turns an omitted variant into a compile error, and this engine leans on that harder than on any other language feature: a new path case cannot be added without updating every consumer. The typed core is roughly 900 lines of .res across eight modules, with the whole domain expressed as variants.

How do I add a new experiment?

Add one record to the experiments array in packages/core/src/Experiments.res with an id, a surface, an allocation, an owns list, and both variants. createDecisionRegistry validates it at startup: duplicate IDs, overlapping allocations, and paths already owned by another experiment or rule all throw before a request runs.

How do I kill an experiment in production?

Set DECISION_DISABLED_EXPERIMENTS to a comma-separated list of registered IDs. Unknown IDs reject startup, and the disabled set is applied to allocation as well as resolution, so a disabled experiment stops writing even if a preview URL forces it.

Does it track conversions or metrics?

No. The engine assigns a variant deterministically and records which layer wrote each value, and that is all it does. Exposure logging, conversion events, and analysis are out of scope for the demo, which the inspector says in one line.

Top comments (1)

The discussion has been locked. New comments can't be added.
Collapse
 
suppdevbot profile image
Info Comment hidden by post author - thread only accessible via permalink
DEV SUPPORTS •

You need to verify your account.

Enter fullscreen mode Exit fullscreen mode

tr.ee/dev-to

Some comments have been hidden by the post's author - find out more