← Blog

Feature flags in TypeScript with a generated client

Feature flags in TypeScript work best when something checks the flag ids before they ship, not just the code around them. dif compiles your flag files into a generated client, and dif validate checks every dif("new-checkout-v2", ...) call site against the real flag ids. Typo the id, and the validator names the file and line before a dashboard-based SDK would have shipped a silent no-op to production.

Most feature flag SDKs take a bare string. dif("new-checkuot-v2", ...) compiles fine, runs fine, and never matches anything, unless something checks the argument against what exists. dif closes that gap by generating the client from your flags and scanning your source against them, instead of asking you to keep the two in sync by hand. This guide covers installing the SDK, what the generated client contains, calling a flag, running a typed A/B test, using flags outside the browser in Node.js, and the one real cost of a codegen step.

Key Takeaways

  • dif build compiles your flag files into dif/generated/client.ts, which registers each flag’s id, variants, weights and salt with the SDK.
  • Referencing a flag id that doesn’t exist, or was renamed, is a W001 warning from dif validate naming the file and line. In 0.6.3 the id parameter itself is typed string, so tsc alone does not catch it.
  • The SDK is about 5 kB gzipped with zero dependencies, runs in the browser or on a Node.js server, and makes no network call to assign a variant.
  • An A/B test uses the same call as a flag: pass a map of variants, and TypeScript infers the return type from the callbacks you wrote.
  • The tradeoff: the generated client only knows about flags as of the last dif build. Skip that step and the client is out of sync with dif/experiments/active/.

Install the CLI and generate a TypeScript client

Install the CLI and scaffold a project. dif init writes a dif/ directory and agent files, with no account or API key:

npm install -g @dif.sh/cli
dif init
npm install @dif.sh/sdk

Draft a flag, then compile it:

dif new new-checkout-v2
dif validate
dif build

dif new writes a Markdown file with frontmatter (owner, surface, hypothesis, variants, weights) and a prose section for the rationale. The draft starts at status: draft; set status: active or dif build skips it. dif validate checks the weights total 100 and the owner is a real email. dif build is the codegen step: it reads every active flag and writes the typed client the SDK docs describe.

What dif build compiles

Each run of dif build rewrites four files from whatever is in dif/experiments/active/:

dif/
├── experiments/
│   └── active/
│       ├── new-checkout-v2.md
│       └── checkout-cta-v2.md
├── generated/
│   ├── client.ts
│   ├── audiences.ts
│   └── events.ts
└── context.json

client.ts registers every active flag with the SDK: id, surface, variants, weights and salt. audiences.ts exports the attributes() helper that resolves the attributes an audience can target. events.ts holds the event delivery config. context.json is a machine-readable summary of every active flag, the one a coding agent reads on session start; it is not something your application code imports.

Call a flag with a typed feature flag client

Import the generated client once, then call dif() from the SDK with a map of branches, one per variant:

import "./dif/generated/client";
import { dif } from "@dif.sh/sdk";

const checkout = dif("new-checkout-v2", {
  off: () => renderLegacyCheckout(),
  on: () => renderNewCheckout(),
});
return checkout();

The difference from a plain string-based SDK shows up the moment you get the id wrong:

$ dif validate
warning W001 src/checkout.ts:5:1: orphan ref: `new-checkuot-v2` is not an active experiment
  help: Either create the experiment or remove the dif() call.

⚠ 1 warning(s)

That works because dif validate scans your .ts, .tsx, .js and .jsx files for dif("...") call sites and compares each id to the active flag files. It is a warning, so it prints in your terminal and in CI and exits 0. The id parameter is typed string in 0.6.3, not a literal string union, so tsc does not flag it, and at runtime an unknown id returns the first branch. For a PM, that means a renamed or removed flag shows up in the validate output instead of silently stranding a component on its default; for the engineer writing the component, it means the validator is doing the grep you’d otherwise run by hand.

Run an A/B test with typed variants

A flag and an experiment are the same call. Pass a map of variants, and dif() returns a function that yields the one this user gets:

const label = dif("checkout-cta-v2", {
  control: () => "Place order",
  variant_a: () => "Get it today",
});

TypeScript infers the return type of label() from the callbacks you wrote, so if one arm returns a number and the other a string, that mismatch is a compile error, not a rendering bug you find in QA. Record the outcome where it happens:

dif.track("completed_checkout", { value: 49 });

When variant_a wins, ramp it by editing the weights in the flag file and running dif build again. The call site in your component does not change.

Feature flags in Node.js

@dif.sh/sdk has zero dependencies and no framework requirement, so a Node.js server runs the same assignment as a browser tab. Resolve the user id per request and call assign(), the request-scoped form, which takes the user id explicitly instead of reading it from dif.init():

import "./dif/generated/client";
import { assign } from "@dif.sh/sdk";
import express from "express";

const app = express();

app.get("/checkout", (req, res) => {
  const flag = assign("new-checkout-v2", { userId: req.user.id, attributes: {} });
  res.json({ variant: flag?.variant ?? "off" });
});

Assignment is a hash of the user id, computed locally, so there is no outbound call to an evaluation service on the request path. The Node.js HTTP module docs cover the request object if you are wiring this into a raw server instead of Express. If your app is React specifically, feature flags in React covers the provider and hook pattern for the same generated client.

The cost of a generated client

A generated client only knows about the flags that existed at the last dif build. Add a flag, rename one, or delete one, and the runtime keeps using the old definitions until you rebuild. Forget that step, and you get a stale client: a new flag that is not registered yet and returns its first branch, or a deleted one that still buckets users.

The fix is to make the rebuild part of the pipeline instead of a manual step. dif init gitignores dif/generated/, so run dif validate && dif build in CI, before your normal type check and app build, and the client is always generated from the flag files in that commit. That is the real cost against a dashboard-based SDK: those take any string with no build-time check at all, so there’s no build step to remember, and no way to catch a typo before it ships. The CLI reference covers wiring validate and build into CI.

Getting started

npm install -g @dif.sh/cli
dif init
npm install @dif.sh/sdk
dif new my-first-flag
dif build

That’s the whole setup: no signup, no dashboard, and the client you import is generated from the flags actually in your repo. The CLI and SDK source are on GitHub, MIT licensed. What feature flags are is the primer, and the feature flags page shows the full workflow, from dif new to dif conclude.

Feature flags in TypeScript work best when the build knows your flag ids, not just your application code. dif generates the client from the files in dif/experiments/active/, checks the call sites in CI, and gives you a function whose return type is inferred from the branches you wrote. A renamed or deleted flag shows up as a W001 warning in the validate output, weeks before it would have shown up as a support ticket.

FAQ

How do I add feature flags to a TypeScript project? Install @dif.sh/cli and @dif.sh/sdk, run dif init to scaffold the dif/ directory, and run dif build to generate the client. Import that client once, then call dif() from @dif.sh/sdk anywhere in your code.

What is a generated feature flag client? It’s the dif/generated/client.ts file dif build compiles from your active flag files. It registers each flag’s id, variants, weights and salt with the SDK, so dif() resolves a variant locally.

Does dif work outside React, in plain Node.js? Yes. @dif.sh/sdk has zero dependencies and runs in the browser or on a Node.js server. The React and Svelte packages are optional adapters, not a requirement.

What happens if I reference a flag id that doesn’t exist? dif validate and dif build print a W001 warning naming the file and line. It is a warning, not a compile error: the id parameter is a bare string in 0.6.3, and at runtime an unknown id returns the first branch.

Do I need to rebuild the client every time I add a flag? Yes. Run dif build after editing a flag file, or the runtime keeps the old definition. Add dif validate && dif build to CI so the client is always generated from dif/experiments/active/.

Is there a lighter-weight TypeScript SDK than LaunchDarkly’s? @dif.sh/sdk is about 5 kB gzipped with zero dependencies. Assignment is computed locally from the user id, so there’s no evaluation service to call and no bundle weight for one.