Feature Flags
@basaltkit/flags evaluates flags against a context — falling back to the current request's tenant and user — with per-tenant/user targeting and deterministic percentage rollouts. Zero dependencies, fully typed.
Define
// src/flags.ts
import { defineFlags } from '@basaltkit/flags'
export const flags = defineFlags({
newDashboard: { default: false, tenants: { acme: true } },
maxUploadMb: { default: 10, tenants: { pro: 100 }, users: { vip: 500 } },
betaSearch: { default: false, rollout: 20 }, // 20% of subjects
euOnly: { default: false, rule: (ctx) => ctx.region === 'eu' || undefined },
})rule receives the full FlagContext — { tenantId?, userId? } plus any extra keys you pass at evaluation time (region above). Returning undefined falls through to the next resolution step.
Wire into an app
Register the typed instance with flagsPlugin so any code can resolve it from the container under the FLAGS token:
// src/app.ts
import { createApp } from '@basaltkit/core'
import { FLAGS, flagsPlugin } from '@basaltkit/flags'
import { flags } from './flags.js'
const app = await createApp({
plugins: [flagsPlugin(flags)],
}).boot()
const resolved = app.container.get(FLAGS)
resolved.enabled('betaSearch', { userId: 'vip' }) // explicit contextKeep autocompletion
The FLAGS token erases the catalog's key types. Import your typed flags instance directly (as above) — or cast the resolved value — to keep key autocompletion and value inference on enabled/value/all.
Evaluate
Inside a request, the tenant and user come from the context automatically — no plumbing per call:
import { flags } from './flags.js' // the typed instance keeps key autocompletion
flags.enabled('newDashboard') // uses the current request's tenant/user
flags.value('maxUploadMb') // → 100 for tenant "pro", 500 for user "vip"
flags.enabled('betaSearch', { userId: 'u1' }) // explicit context override
flags.value('euOnly', { region: 'eu' }) // custom context key read by `rule`
flags.all() // resolve everything — e.g. to seed a clientEnd to end: gate a route and seed the client
import { z } from 'zod'
import { route } from '@basaltkit/fastify'
import { HttpError } from '@basaltkit/fastify'
import { flags } from './flags.js'
// Gate a server route — context (tenant/user) is implicit inside the handler.
export const dashboard = route({
method: 'GET',
url: '/dashboard',
handler() {
if (!flags.enabled('newDashboard')) throw new HttpError(404, 'Not found')
return { layout: 'v2', maxUploadMb: flags.value('maxUploadMb') }
},
})
// Bootstrap the browser: resolve everything once and ship it to the client.
export const bootstrap = route({
method: 'GET',
url: '/bootstrap',
handler: () => ({ flags: flags.all() }),
})Resolution order
Most specific wins:
rule— a custom predicate (returnundefinedto fall through)users[userId]— explicit per-user overridetenants[tenantId]— explicit per-tenant overriderollout— deterministic bucket for boolean flags (a subject always gets the same answer, so a rollout is stable as it widens)default
Because evaluation reads the request context automatically, the same flags.enabled('x') call returns the right answer per tenant with no plumbing.