Typed contexts
defineContextSchema() creates a compile-time contract between the attributes your application passes to the SDK and the attribute keys declared in your flag targeting rules (the conditions that decide what each visitor sees). Mismatches, like a typo in a field name, a type mismatch, or a removed attribute still referenced in a rule, become TypeScript errors before they ship.
Coming from the getting-started journey and just want the types? Run npx @avsbhq/cli codegen --project PRJ-42 --output ./src/generated/flags.ts (inside a folder created by avsb project pull or avsb clone, --project can be left out).
What it is
Without typed contexts, the evaluation context is a plain object with an open [attribute: string]: unknown index signature, so any key is allowed. You can write usrId: 'u_1' instead of userId: 'u_1' and nothing warns you. The SDK evaluates silently: your visitor falls through to the default variation, meaning one specific version being tested, like the control, because the targeting condition never fires.
defineContextSchema() replaces the open bag with a Zod-validated, fully typed builder. The schema lives next to your SDK client setup, so TypeScript enforces it everywhere a context gets built: in components, in middleware, and in tests.
import { z } from 'zod'import { defineContextSchema } from '@avsbhq/utils'// Declare the shape onceexport const Ctx = defineContextSchema({ user: z.object({ id: z.string(), plan: z.enum(['free', 'pro', 'enterprise']), country: z.string().optional(), }), organization: z.object({ id: z.string(), tier: z.number().int().min(1).max(5), }),})// Build contexts with full type-checkingconst context = Ctx.builder() .setUser('u_123', { id: 'u_123', plan: 'pro', country: 'US' }) .setOrganization('o_42', { id: 'o_42', tier: 3 }) .build()A typo no longer compiles. This is the failure the schema exists to produce, so it is shown here as the compiler output rather than as code you could run:
Ctx.builder().setUser('u_123', { usrId: 'u_123', plan: 'pro' })// ^^^^^ TS2353: Object literal may only specify// known properties, and 'usrId' does not exist// in type Partial<{ id: string; plan: ... }>When to use it
Typed contexts have the highest value when:
- Your flag targeting rules use many custom attributes, like plan tiers, roles, or feature flags on the user record (settings that turn a feature on or off without a new deploy). You want TypeScript to check that these attribute keys match between your rules and your SDK calls.
- You use the CLI's
avsb codegencommand, which emits aContextSchemainterface matching your platform's declaredregisteredAttributes. This creates an end-to-end type chain: platform schema → generated TypeScript → SDK call sites. - Your codebase has multiple teams passing context objects and you want a single source of truth for the attribute shape.
When not to use it
Skip typed contexts when:
- Your codebase is plain JavaScript, not TypeScript. The main benefit, a typo that cannot compile, is a TypeScript-only guarantee. A JavaScript file can still call
Ctx.validate(ctx)for a runtime check, but it never gets the "the typo cannot ship" protection. - You only have one or two attributes. Wrapping a two-field context in a schema, a builder, and a generated file adds more setup than it saves. Build the plain context object shown in SDK Installation instead.
- Your attributes are still changing every day. Early on, keeping the schema, the generated file, and every call site in sync during heavy churn costs more time than the typos it would catch. Add typed contexts once your targeting attributes settle down.
How it works
defineContextSchema returns an object with two members:
builder(): returns a typedContextBuilderwhose setter methods accept only the declared attribute shape for the given kind. The returnedEvalContextfrombuild()is a plain object compatible with every SDK API.validate(ctx): runs the Zod schema against anyEvalContextand returns{ success: true, data }on success or{ success: false, error: { issues } }on failure. This is a small result shape of its own, not Zod'sSafeParseReturnType(Zod's version types the error as aZodError, not a plain issues array). Useful in middleware to verify incoming contexts before forwarding to the evaluator.
import type { EvalContext } from '@avsbhq/node'// Your app, your request type, and your own two helpers.interface AvsbRequest { avsbContext?: EvalContext}declare const app: { use(handler: (req: AvsbRequest, res: unknown, next: () => void) => void): void}declare function buildContextFromRequest(req: AvsbRequest): EvalContextdeclare const logger: { warn(message: string, meta: Record<string, unknown>): void }// Runtime validation in Express middlewareapp.use((req, res, next) => { const raw = buildContextFromRequest(req) const result = Ctx.validate(raw) if (!result.success) { logger.warn('invalid avsb context', { errors: result.error.issues }) // Fall through with a safe anonymous context req.avsbContext = { kind: 'user', key: 'anonymous' } } else { req.avsbContext = result.data } next()})CLI codegen integration
The avsb codegen command reads your platform project's declared context kinds and attribute definitions and emits a typed schema file:
npx @avsbhq/cli codegen --output ./src/generated/flags.ts --project 42The generated file exports three types:
// generated/flags.ts (example output)export type FlagKey = | 'checkout_v2' | 'homepage_hero' | 'pricing_experiment'export interface FlagValues { 'checkout_v2': boolean 'homepage_hero': 'control' | 'variant-a' | 'variant-b' // codegen reads every variation's actual value and infers a real shape, // marking a key optional (`?`) when one variation is missing it. Only past // a size or nesting limit does it give up and emit `unknown`. 'pricing_experiment': { discountPercent: number; freeShipping?: boolean }}export interface ContextSchema { 'organization': { key: string; 'tier': number } 'user': { key: string; 'plan': string }}Every key here, including the context kind itself, is quoted in the real output, and an attribute's type is always one of string, number, boolean or unknown (never a literal union like 'free' | 'pro'): the platform stores a coarse type per attribute, not its possible values.
Import ContextSchema into your defineContextSchema call to keep your local schema in sync with the platform:
import { z } from 'zod'import { defineContextSchema } from '@avsbhq/utils'import type { ContextSchema } from './generated/flags'// Zod schema derived from the generated interfaceexport const Ctx = defineContextSchema({ user: z.object({ key: z.string(), plan: z.enum(['free', 'pro', 'enterprise']), } satisfies Record<keyof ContextSchema['user'], z.ZodTypeAny>), organization: z.object({ key: z.string(), tier: z.number(), } satisfies Record<keyof ContextSchema['organization'], z.ZodTypeAny>),})Add avsb codegen to your postinstall or pre-build step so the generated file is always fresh when you install dependencies or deploy.
Keeping the generated file honest
There is no avsb lint. Re-run avsb codegen and let your own type-check catch drift. If a targeting attribute disappears from the platform, it stops existing in ContextSchema. The satisfies clause above then turns that gap into a compile error in your code, not a surprise at runtime.
Related concepts
- Multi-context identity: the
EvalContextshapedefineContextSchemabuilds against. - CleanupRegistry: ships alongside defineContextSchema in @avsbhq/utils
- SDK Installation: identifying users and passing attributes