3. Schema-driven application code
← Previous: Lint on every commit and every PR · Index · Next: Fixtures and automated tests
Lint proves the manifest is well-formed. Chapter 1's exd explain walk told you what context attributes the flag reads. Now turn that into typed application code — and a one-line test that catches drift between the manifest and the code if either of them moves.
The tool for this is exd schema. It walks every predicate atom and every [segment.bucket].entity_id_attribute in your flag (or the whole namespace), infers the type of each attribute from the operators applied to it, and emits the result in one of five formats — human, json, jsonschema, rust, typescript.
Discover the context shape
Run it against the flag in human format first, just to read what comes out:
$ exd schema welcome-banner --env production --manifest marketing
# Manifest fingerprint: marketing@a1b2c3
schema for flag 'welcome-banner' in env 'production'
user.id string required sources:
- segments/welcome-banner-bucket-control.toml [segment.bucket].entity_id_attribute
- segments/welcome-banner-bucket-treat-a.toml [segment.bucket].entity_id_attribute
- segments/welcome-banner-bucket-treat-b.toml [segment.bucket].entity_id_attribute
user.country string required sources:
- segments/welcome-banner-bucket-control.toml predicate `eq "US"`
- segments/welcome-banner-bucket-treat-a.toml predicate `eq "US"`
- segments/welcome-banner-bucket-treat-b.toml predicate `eq "US"`
Two attributes, both string, both required. user.id is required because the bucket segments hash it; user.country is required because the segment predicates compare it. The sources list tells you every site in the manifest that drove the inference — useful for understanding why a type was inferred, and for finding what to edit if the type ever needs to change.
The fingerprint line (marketing@a1b2c3) is a short hash of the canonicalized manifest. Two exd invocations against the same files produce the same fingerprint, so generated output is comparable across machines.
Codegen a typed context struct
Now emit the schema in the language of your application code. Same command, different --format.
Rust
$ exd schema welcome-banner --env production --manifest marketing --format rust > src/contexts.rs
$ cat src/contexts.rs
// Generated by `exd schema welcome-banner --env production --format rust`.
// Manifest fingerprint: marketing@a1b2c3 — rerun if predicates change.
pub struct WelcomeBannerContext {
pub user_id: String, // required: segments/welcome-banner-bucket-control.toml [segment.bucket].entity_id_attribute
pub user_country: String, // required: segments/welcome-banner-bucket-control.toml predicate `eq "US"`
}
TypeScript
$ exd schema welcome-banner --env production --manifest marketing --format typescript > src/contexts.ts
$ cat src/contexts.ts
// Generated by `exd schema welcome-banner --env production --format typescript`.
// Manifest fingerprint: marketing@a1b2c3 — rerun if predicates change.
export interface WelcomeBannerContext {
"user.id": string; // required
"user.country": string; // required
}
Check the generated file into the repo. Re-run the command when the manifest changes — the fingerprint line shifts, and the diff lands cleanly in code review. A predicate that newly references user.tier would show up here as a fresh field, with the source citation pointing straight at the line of TOML that added it.
Two attributes today, more tomorrow. This codegen step earns its keep when the schema grows. With two required strings, you could hand-author the struct in a minute. With twelve attributes across five segments — including an optional
user.beta_cohort: Option<i64>whose type was inferred from anin [1, 2, 3]predicate — the generated form is the only one that stays correct as the manifest evolves.
Write the eval call site
With the typed struct in hand, the application code that reads the flag becomes mechanical. Pass user.id (drives bucketing) and user.country (drives the US gate) every time.
Rust — src/banner.rs
use exd_client::{EvalContext, ExdClient};
use crate::contexts::WelcomeBannerContext; // generated by `exd schema`
pub async fn render_banner(
client: &ExdClient,
request: WelcomeBannerContext,
) -> String {
let ctx = EvalContext::builder()
.str("user.id", &request.user_id)
.str("user.country", &request.user_country)
.build();
client
.string_flag("welcome-banner", &ctx, "Welcome.".to_string())
.await
}
TypeScript — src/banner.ts
import type { ExdClient } from "@exd/client";
import type { WelcomeBannerContext } from "./contexts";
export function renderBanner(
client: ExdClient,
request: WelcomeBannerContext,
): string {
return client.evalString(
"welcome-banner",
"production",
{
"user.id": request["user.id"],
"user.country": request["user.country"],
},
"Welcome.",
);
}
The trailing "Welcome." is the default the SDK returns when the flag is missing or fails to evaluate — independent of the manifest's catch-all variant. Keep it boring; it's a safety net, not a behavior.
Guard against drift with validate_context
The codegen above is checked in. The eval call site reads from the codegen. Two paths can still drift apart silently:
- Someone edits the manifest and forgets to regenerate
contexts.rs/contexts.ts. - Someone edits the application code's context-building helper and stops populating an attribute the manifest expects.
For both, the Rust SDK and the WASM bridge expose Namespace::validate_context (WasmNamespace.validateContext in TS) — it compares an EvalContext against the inferred schema for the given environment and returns the list of missing / wrong-typed attributes. Add a one-line test:
use exd_client::{EvalContext, Namespace};
#[test]
fn eval_context_matches_manifest_schema() {
let ns = Namespace::from_dir("marketing").expect("manifest lints");
let ctx = EvalContext::builder()
.str("user.id", "test-user")
.str("user.country", "US")
.build();
ns.validate_context("production", &ctx).expect("schema drifted");
}
The test fails (and points at the offending attribute) the moment any of these happen:
- A new required attribute is added to the manifest but the context builder doesn't populate it.
- An attribute's inferred type drifts (e.g., a
gt 0predicate is added on a previously string-only attribute). - The codegen and the manifest fall out of sync — the test fails against the current manifest, which is what matters at runtime.
This test runs as part of your regular cargo test, so it's free CI insurance.
Next
Chapter 4 — Fixtures and automated tests. The context shape is now correct; next, prove the behavior — which variants come out for which inputs — with generated fixtures.