Skip to main content

Typed Configuration

Digital Alchemy can use a provider's native schema as the configuration contract. The schema owns defaults, descriptions, optionality, validation, and transforms; the injected config object exposes the provider's output type.

Install a provider​

@digital-alchemy/core declares TypeBox and Zod as optional peers. Install the provider used by your application:

yarn add @digital-alchemy/core typebox

Zod 4 is also supported directly:

yarn add @digital-alchemy/core zod

Declare schemas​

Use exactly one of schema or the legacy type field per entry. Put defaults and descriptions inside the native schema:

import { Type } from "typebox";
import { CreateApplication } from "@digital-alchemy/core";

export const MY_APP = CreateApplication({
name: "my_app",
configuration: {
API_URL: {
schema: Type.String({ description: "Base URL for the upstream API" }),
},
PORT: {
schema: Type.Integer({ default: 3000, description: "HTTP port" }),
},
DEBUG: {
schema: Type.Boolean({ default: false }),
},
ALLOWED_ORIGINS: {
schema: Type.Array(Type.String(), {
default: ["http://localhost:3000"],
}),
},
ENVIRONMENT: {
schema: Type.Union([
Type.Literal("local"),
Type.Literal("staging"),
Type.Literal("production"),
], { default: "local" }),
},
LABEL: {
schema: Type.Optional(Type.String({ description: "Optional status label" })),
},
},
services: { /* ... */ },
});

A bare schema such as Type.String() rejects a missing value during final processing. Use Type.Optional(...) when omission is valid. There is no outer required flag for schema entries.

Read the output after PostConfig​

Service factories run before external sources settle. Register configuration-dependent work in onPostConfig or a later hook:

export function ApiService({ config, lifecycle }: TServiceParams) {
lifecycle.onPostConfig(() => {
const port: number = config.my_app.PORT;
const environment: "local" | "staging" | "production" =
config.my_app.ENVIRONMENT;
const origins: string[] = config.my_app.ALLOWED_ORIGINS;

startServer({ port, environment, origins });
});
}

The config types above are schema outputs. If a schema transforms input—for example, a TypeBox codec or a Zod transform—the input accepted by bootstrap and loaders can be a different type. See Schema-backed Configuration.

Supplying values​

Normal bootstrap reads environment and CLI sources, then an optional config file and custom loaders. The bootstrap configuration object is an early schema-input seed, not an unconditional highest-priority override:

await MY_APP.bootstrap({
configuration: {
my_app: {
API_URL: "https://api.example.com",
PORT: 8080,
ENVIRONMENT: "staging",
},
},
configSources: {
file: false,
},
});

Use the documented underscore form for environment and CLI keys:

MY_APP__PORT=8080 node dist/app.mjs --my_app__ENVIRONMENT=staging

Within the environment/argv pass, a matching CLI value wins over an environment value. All enabled sources settle before native schema processing and onPostConfig.

Try it live​

The editor uses TypeBox schemas for the app, including an optional value and provider-native defaults. Open api.service.mts to inspect the inferred output types.

Loading...

Legacy entries during migration​

The legacy form remains valid and can be mixed with schemas:

configuration: {
OLD_PORT: { type: "number", default: 3000 },
NEW_PORT: { schema: Type.Integer({ default: 3000 }) },
}

Legacy enum narrows TypeScript but does not enforce runtime membership, and legacy string[] keeps its CSV parsing. Use native schemas when you need runtime validation or provider-owned structured input. See Legacy Configuration Types for compatibility behavior.

Next: Service Returns →