Configuration
The configuration system gives you a structured, type-safe way to manage application settings in Stratal. It is environment-agnostic — the framework never reads environment variables directly. Instead, your application defines config namespaces that extract values from the Cloudflare Env object and expose them through a centralized ConfigService.
Key capabilities:
registerAs()for creating typed config namespaces with auto-derived DI tokens- Dot-notation access via
ConfigService(e.g.,config.get('database.url')) - Optional Zod schema validation at startup
- Runtime overrides via
config.set()for request-scoped values
How it works
Section titled “How it works”Configuration flows through three steps:
- Define — create config namespaces with
registerAs(), each receiving theEnvobject and returning a typed config object. - Register — pass all namespaces to
ConfigModule.forRoot()in your root or shared module. The module resolves the CloudflareEnv, calls each factory, optionally validates the merged result, and initializesConfigService. - Use — inject
ConfigServiceanywhere and access values with dot-notation paths.
Defining config namespaces
Section titled “Defining config namespaces”Use registerAs() to create a config namespace. The first argument is the namespace name, and the second is a factory function that receives the Env object:
import { registerAs } from 'stratal/config'
export const databaseConfig = registerAs('database', (env: Env) => ({ url: env.DATABASE_URL || '', maxConnections: parseInt(env.DATABASE_MAX_CONNECTIONS || '10', 10),}))
export type DatabaseConfig = ReturnType<typeof databaseConfig.factory>The returned object (a ConfigNamespace) has these properties:
| Property | Type | Description |
|---|---|---|
KEY | Symbol | Auto-derived DI token for injecting this namespace |
namespace | string | The namespace name passed to registerAs() |
factory | (env: Env) => T | The factory function that produces the config object |
asProvider() | () => Provider | Returns a provider definition for module registration |
Here is a second namespace for email settings:
import { registerAs } from 'stratal/config'
export const emailConfig = registerAs('email', (env: Env) => ({ provider: env.EMAIL_PROVIDER || 'mailchannels', from: { name: env.EMAIL_FROM_NAME || 'App', email: env.EMAIL_FROM_ADDRESS || 'noreply@example.com', }, queue: env.EMAIL_QUEUE,}))
export type EmailConfig = ReturnType<typeof emailConfig.factory>Registering configuration
Section titled “Registering configuration”Import ConfigModule in your root module (or a shared CoreModule) and call forRoot() with your namespaces:
import { Module } from 'stratal/module'import { ConfigModule } from 'stratal/config'import { databaseConfig, emailConfig, storageConfig } from '../config'
@Module({ imports: [ ConfigModule.forRoot({ load: [databaseConfig, emailConfig, storageConfig], validateSchema: AppConfigSchema, // Optional Zod schema }), ],})export class CoreModule {}ConfigModule.forRoot() accepts a ConfigModuleOptions object:
| Property | Type | Required | Description |
|---|---|---|---|
load | ConfigNamespace[] | Yes | Array of config namespaces to register |
validateSchema | ZodSchema | No | A Zod schema to validate the merged config at startup |
Injecting and using ConfigService
Section titled “Injecting and using ConfigService”Inject ConfigService into any provider using CONFIG_TOKENS.ConfigService:
import { inject, Transient } from 'stratal/di'import { CONFIG_TOKENS, type IConfigService } from 'stratal/config'
@Transient()export class MyService { constructor( @inject(CONFIG_TOKENS.ConfigService) private readonly config: IConfigService ) {}
getConnectionUrl(): string { return this.config.get('database.url') }}The IConfigService interface provides these methods:
| Method | Return type | Description |
|---|---|---|
get(path) | T | Get a value using dot-notation (e.g., 'email.from.name') |
set(path, value) | void | Override a value at runtime |
reset(path?) | void | Reset a specific path or the entire config to original values |
has(path) | boolean | Check whether a config path exists |
all() | Readonly<T> | Get the entire merged config object |
// Read nested values with dot notationconst fromName = this.config.get('email.from.name')
// Check before accessingif (this.config.has('database.url')) { const url = this.config.get('database.url')}Type safety with ModuleConfig
Section titled “Type safety with ModuleConfig”To get autocompletion and type checking on config.get() paths, augment the ModuleConfig interface using TypeScript module augmentation:
import type { DatabaseConfig } from './database.config'import type { EmailConfig } from './email.config'
declare module 'stratal' { interface ModuleConfig { database: DatabaseConfig email: EmailConfig }}After this augmentation, config.get('database.url') is fully typed — your editor will autocomplete valid paths and flag invalid ones at compile time.
Schema validation
Section titled “Schema validation”Pass a Zod schema to validateSchema to validate the merged config object at startup. If validation fails, Stratal throws a ConfigValidationError before the application starts:
import { z } from 'zod'
export const AppConfigSchema = z.object({ database: z.object({ url: z.string().min(1), maxConnections: z.number().positive(), }), email: z.object({ provider: z.string(), from: z.object({ name: z.string(), email: z.string().email(), }), }),})import { ConfigModule } from 'stratal/config'import { AppConfigSchema } from '../config/app-config.schema'
ConfigModule.forRoot({ load: [databaseConfig, emailConfig], validateSchema: AppConfigSchema,})If required environment variables are missing or have the wrong type, the application fails fast with a clear error:
ConfigValidationError: Configuration validation failedRuntime overrides
Section titled “Runtime overrides”Use config.set() to override config values during a request. This is useful for middleware that adjusts settings based on request context:
// In middleware — override for this requestasync handle(ctx: RouterContext, next: () => Promise<void>) { this.config.set('email.from.name', 'Custom Name') await next()}
// In a downstream service — reflects the overrideasync sendEmail() { const fromName = this.config.get('email.from.name') // 'Custom Name'}To restore original values, call reset():
// Reset a specific paththis.config.reset('email.from.name')
// Reset the entire configthis.config.reset()Feeding config to other modules
Section titled “Feeding config to other modules”Framework modules like StorageModule and EmailModule use forRootAsync to receive their options from config namespaces. Pass the namespace’s KEY token in the inject array:
import { Module } from 'stratal/module'import { ConfigModule } from 'stratal/config'import { StorageModule } from 'stratal/storage'import { EmailModule } from 'stratal/email'import { databaseConfig, emailConfig, storageConfig } from '../config'
@Module({ imports: [ ConfigModule.forRoot({ load: [databaseConfig, emailConfig, storageConfig], }),
StorageModule.forRootAsync({ inject: [storageConfig.KEY], useFactory: (storage) => ({ storage: storage.storage, defaultStorageDisk: storage.defaultStorageDisk, }), }),
EmailModule.forRootAsync({ inject: [emailConfig.KEY], useFactory: (email) => ({ provider: email.provider, from: email.from, queue: email.queue, }), }), ],})export class CoreModule {}Custom config services
Section titled “Custom config services”For application-specific helpers like isDevelopment(), create a wrapper service around ConfigService:
import { inject, Transient } from 'stratal/di'import { CONFIG_TOKENS, type IConfigService } from 'stratal/config'
@Transient()export class AppConfigService { constructor( @inject(CONFIG_TOKENS.ConfigService) private readonly config: IConfigService ) {}
isDevelopment(): boolean { return this.config.get('app.environment') === 'development' }
getApiBaseUrl(): string { return this.config.get('app.apiBaseUrl') }}Register AppConfigService as a provider in your module and inject it wherever you need the convenience methods.
Adding new configuration
Section titled “Adding new configuration”To add a new config namespace to your application:
-
Create the namespace file
src/config/new-feature.config.ts import { registerAs } from 'stratal/config'export const newFeatureConfig = registerAs('newFeature', (env: Env) => ({apiKey: env.NEW_FEATURE_API_KEY || '',enabled: env.NEW_FEATURE_ENABLED === 'true',timeout: parseInt(env.NEW_FEATURE_TIMEOUT || '5000', 10),}))export type NewFeatureConfig = ReturnType<typeof newFeatureConfig.factory> -
Add to your barrel export
src/config/index.ts export * from './new-feature.config'export const allConfigs = [// ... existing configsnewFeatureConfig,] -
Add a Zod schema (optional)
src/config/app-config.schema.ts export const AppConfigSchema = z.object({// ... existing schemasnewFeature: z.object({apiKey: z.string(),enabled: z.boolean(),timeout: z.number(),}),}) -
Set environment variables — add to
.dev.vars.examplefor local development and set in production viawrangler secret put(sensitive) orwrangler.jsonc(non-sensitive).
Testing
Section titled “Testing”Use ConfigModule.forRoot() inside Test.createTestingModule to load config in tests:
import { Test, type TestingModule } from '@stratal/testing'import { CONFIG_TOKENS, type IConfigService } from 'stratal/config'import { ConfigModule } from 'stratal/config'import { databaseConfig, emailConfig } from '../config'
describe('MyService', () => { let module: TestingModule
beforeAll(async () => { module = await Test.createTestingModule({ imports: [ ConfigModule.forRoot({ load: [databaseConfig, emailConfig], }), ], }).compile() })
it('should resolve config values', () => { const config = module.get<IConfigService>(CONFIG_TOKENS.ConfigService) expect(config.get('database.url')).toBeDefined() })})For more on testing patterns, see the Testing Overview.
Troubleshooting
Section titled “Troubleshooting”ConfigValidationError at startup
Section titled “ConfigValidationError at startup”ConfigValidationError: Configuration validation failedCheck that all required environment variables are set and match the Zod schema types. Missing or incorrectly typed variables will cause validation to fail.
Type errors with config paths
Section titled “Type errors with config paths”Argument of type '"invalid.path"' is not assignableVerify that you are using the correct dot-notation path matching your namespace structure, and that your ModuleConfig augmentation includes the namespace. For example, if your namespace defines { database: { url: string } }, the valid path is 'database.url'.
ConfigService not initialized
Section titled “ConfigService not initialized”Error: ConfigService not initializedEnsure ConfigModule.forRoot() is imported in your module chain. The root module or a shared CoreModule must include it in its imports array.
Next steps
Section titled “Next steps”- Modules to learn how modules are structured and composed.
- Providers for all the ways you can register services.
- Environment Typing to type the
Envobject that config factories receive. - Testing Overview for testing strategies with configuration.