Language: English
Keep OFF-Flag Code Out of Your Build Artifacts
Runtime-evaluated feature flags leave OFF-branch code inside the build artifacts. This article describes fixing that by pinning flags at build time so dead code elimination removes them physically, and verifying removal in CI with marker checks.
With development close to trunk-based, we want develop and main deployable at all times. Feature flags are how we get there.
The trouble is, with conventional flags evaluated at runtime, the OFF code still ends up in the build artifact. Unfinished features ship to production inside the bundle. One mistake in flag evaluation and they run; even without one, they still add to artifact size.
So we pinned the flags at build time, in a form that static analysis can see.
The Two Guarantees We Wanted
The first is guaranteeing flag names at the type level. Referencing a nonexistent flag, a missing environment key, a value that isn’t a boolean — all of these become compile errors.
The second is that code behind an OFF flag is physically absent from the artifact. The frontend relies on webpack dead code elimination. The backend drops it via a generated file that wires only ON flags, plus dist pruning. And CI verifies that it really disappeared by grepping for markers.
Five Layers
単一の TS ソース定義
→ 環境別の codegen(boolean リテラルを生成、未知の環境は throw)
→ コンパイル定数として参照
→ フラグ配下コードのディレクトリ分離(src/features/<flag-name>/)
→ 成果物マーカー検証 CI
Codegen emits boolean literals per environment. An unknown environment throws. There is no fallback. Falling back to a default here would open a path where a flag turns ON in an environment nobody intended.
canaryOn / canaryOff
The scariest failure mode of this whole mechanism is the verification itself being broken. If the marker grep always succeeds, nobody notices.
So we added two permanent flags, canaryOn and canaryOff.
Neither ever gets removed.
Every build checks that the ON marker is present in the artifact and the OFF marker is absent.
In effect, CI tests the property “OFF disappears, ON survives” on itself every single run.
We actually built with ENVIRONMENT=prd and confirmed it in both the backend dist and the frontend .next.
Lifecycle
- Add: declare values for all four environments in the definition, put the implementation and markers in
src/features/<flag>/, and register it in the dispatcher - Promote: a one-line PR flipping stg and prd to true
- Remove: replace dispatcher calls with direct calls, then delete one line from the definition
If references remain at removal time, compilation fails. Types prevent the “flag is gone but code lingers” state.
The preview environment inherits dev’s flag values. Since preview builds from PR code, turning dev to true within a PR lets you verify before merging.
Import Conventions for Flag Directories
After the core landed, PR-2 added conventions for the boundary.
The trigger was an incident path pointed out during the PR-1 review.
Importing a flag’s directory directly from elsewhere leaves that import in place.
Meanwhile dist pruning removes the flag directories.
The result: MODULE_NOT_FOUND at production startup.
The conventions come in three layers.
| Layer | Role | CI |
|---|---|---|
| Architecture guardrails (ts-morph) | The authoritative enforcement. Path-resolution-based; cannot be bypassed | Yes |
ESLint no-restricted-imports | Immediate feedback in the editor and at pre-commit | No |
| Documentation | Conventions and operational procedures | No |
In the backend, imports into flag directories are restricted to the codegen wiring and directories of the same flag. Resolution is string-based, so the checks hold even before any generated files exist.
In the frontend, imports into flag directories are restricted to the dispatcher and the same directory, with an added check matching markers against definitions. This now catches directories forgotten for deletion after their feature was baked in, and typos in flag names.
Flag names are required to contain a hyphen once kebab-cased.
newCheckout passes, but checkout does not.
Codegen throws and fails fast.
This keeps flag namespaces from colliding with existing single-word domain names, and ESLint static patterns can detect it too.
Dividing the Work Between ts-morph and ESLint
ESLint could only cover the part that static patterns can judge.
“Which flag directory does this import resolve to?” can’t be answered without resolving the path. That part is implemented on the ts-morph side and runs as a CI gate.
We couldn’t write the convention in one place because the tools’ coverage differs. ts-morph owns the unbypassable enforcement; ESLint owns feedback while you type. As long as both say the same thing, developers can trust the editor warnings and keep writing.