Get started / TypeScript (T3 Env)
docuconf for TypeScript (T3 Env)
You keep writing createEnv({ server }) with Zod 4. docuconf re-exports T3's createEnv and adds helpers for what Zod has no word for: secret, url({ schemes }), duration and list.
- Builds on
- T3 Env + Zod 4
- Package
- @docuconf/t3 (npm)
- Requires
- Node 22.12
- Status
- v0.1 alpha, not yet released · conformance 131 of 134 (skips int64, json-schema)
- Source
- docuconf-js · the orders example
Not published yet
main branch. After the first release it will be npm install @docuconf/t3 @t3-oss/env-core zod.The code below is the orders service, the example every SDK ships: six variables, one of them a secret, checked the same way in every language. Compare it across languages.
1.Install
The packages are not on npm yet. Build them from the main branch and install the packed tarballs, with T3 Env and Zod 4 (Node 22.12 or later):
git clone https://github.com/Docuconf/docuconf-js ../docuconf-js
(cd ../docuconf-js && npm ci && npm run build && npm pack -w @docuconf/core -w @docuconf/t3)
npm install ../docuconf-js/docuconf-core-0.1.0.tgz ../docuconf-js/docuconf-t3-0.1.0.tgz @t3-oss/env-core zod2.Declare your configuration
A T3 Env declaration with a name for the contract. Use docuconf's url({ schemes }) rather than z.url({ protocol }), and duration({ default }) rather than .default("30s"): both are exported to the contract, and Zod's own forms are not. .describe() is the description, and a TSDoc comment above a variable adds its details. Keep createEnv in its own module, so exporting it does not start your server.
// The service's configuration: a T3 Env declaration with docuconf's helpers
// for what Zod has no word for (secrets, URL schemes, durations, lists).
import { z } from "zod";
import { createEnv, duration, list, secret, url } from "@docuconf/t3";
export const env = createEnv({
name: "orders",
server: {
PORT: z.coerce.number().int().min(1).max(65535).default(8080).describe("Port the HTTP server listens on"),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info").describe("Minimum log level emitted"),
DATABASE_URL: secret(url({ schemes: ["postgres"], maxLength: 2048 })).describe("Postgres connection string for the orders database"),
ALLOWED_ORIGINS: list(z.string(), { minItems: 1 })
.default(["http://localhost:3000"])
.describe("Comma-separated CORS origins allowed to call the API"),
REQUEST_TIMEOUT: duration({ min: "1s", max: "5m", default: "30s" }).describe("Timeout for a single request"),
/**
* Number of background order workers.
*
* Each worker holds one database connection, so keep this below the
* pool size of {@link DATABASE_URL}'s server.
*
* - Raise it when the order queue backs up.
* - Lower it when the database is the bottleneck.
*/
WORKER_COUNT: z.coerce.number().int().min(1).max(64).default(4).describe("Number of background order workers"),
},
runtimeEnv: process.env,
// On invalid configuration: print every problem and exit 1.
exitOnError: true,
});3.Load it at boot
Importing env validates the environment, so do it before anything else starts. env.PORT is a number and env.REQUEST_TIMEOUT is milliseconds.
import { env } from "./env.js";4.Run it, and see an error
Run the example with a valid environment:
npm run build && DATABASE_URL=postgres://orders:pw@localhost:5432/orders node dist/server.jsWith PORT=0 and no DATABASE_URL, the app does not start. exitOnError: true, in env.ts above, prints every problem with its error code and exits 1. Without it, createEnv throws a DocuconfValidationError listing the same problems.
$ PORT=0 node dist/server.js
docuconf: 2 configuration problems:
- PORT [out_of_range]: Too small: expected number to be >=1 (got "0")
- DATABASE_URL [missing_required]: required, but not set5.Test your configuration
env.ts reads process.env when it is imported, so the test loads it in a child process with exactly the environment it wants. It uses Node's built-in test runner.
// node --test src/env.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
// Load src/env.ts in a child process with exactly this environment,
// so the test never reads or changes its own process.env.
function load(env: Record<string, string>) {
const script = 'const { env } = await import("./src/env.ts"); console.log(env.PORT, env.WORKER_COUNT);';
return spawnSync(process.execPath, ["--input-type=module", "-e", script], {
env: { PATH: process.env.PATH, DOCUCONF_TERMINATION_LOG: "/dev/null", ...env },
encoding: "utf8",
});
}
test("defaults", () => {
const r = load({ DATABASE_URL: "postgres://orders@db/orders" });
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout.trim(), "8080 4");
});
test("rejects bad values", () => {
const r = load({ PORT: "70000" });
assert.equal(r.status, 1);
assert.match(r.stderr, /PORT \[out_of_range\]/);
assert.match(r.stderr, /DATABASE_URL \[missing_required\]/);
});node --test src/env.test.ts6.Export the contract
Export imports the module in export mode: createEnv records the declaration and checks nothing, so no environment is needed. --check contract.cue fails when the committed file is out of date, for CI.
npx docuconf-t3 export src/env.ts --out contract.cue// Code generated by docuconf. DO NOT EDIT.
package orders
import "docuconf.dev/contract"
contract.#Contract & {
apiVersion: "docuconf.dev/v1alpha1"
kind: "ConfigContract"
metadata: {
name: "orders"
generator: {
language: "typescript"
sdk: "@docuconf/t3"
version: "0.1.0"
}
}
vars: {
ALLOWED_ORIGINS: {
type: "list"
description: "Comma-separated CORS origins allowed to call the API"
items: "string"
encoding: "csv"
separator: ","
minItems: 1
default: ["http://localhost:3000"]
}
DATABASE_URL: {
type: "url"
description: "Postgres connection string for the orders database"
required: true
secret: true
schemes: ["postgres"]
maxLength: 2048
}
LOG_LEVEL: {
type: "enum"
description: "Minimum log level emitted"
values: ["debug", "info", "warn", "error"]
default: "info"
}
PORT: {
type: "int"
description: "Port the HTTP server listens on"
min: 1
max: 65535
default: 8080
}
REQUEST_TIMEOUT: {
type: "duration"
description: "Timeout for a single request"
encoding: "go"
min: "1s"
max: "5m"
default: "30s"
}
WORKER_COUNT: {
type: "int"
description: "Number of background order workers"
details: "Each worker holds one database connection, so keep this below the\npool size of `DATABASE_URL`'s server.\n\n- Raise it when the order queue backs up.\n- Lower it when the database is the bottleneck."
min: 1
max: 64
default: 4
}
}
}The platform then checks each environment's values against contract.cue before it deploys, with docuconf vet, the Helm chart or CUE. See How it works.
7.Next.js and NestJS
In Next.js, only server variables are runtime configuration: T3's client section is inlined at build time, so it is validated as T3 does but never exported. For NestJS, use @docuconf/nestjs, which plugs into ConfigModule.forRoot({ validate }) with class-validator; to keep Zod in a Nest app, pass (config) => createEnv({ name, server, runtimeEnv: config }) as the validate function.
Next
- The docuconf-js README: every type, file input and option.
- The orders example, with its smoke test.
- The same service in every language, side by side.
- What every SDK supports, and this one's known gaps: ESM (.mjs) and CommonJS both supported. int is limited to the safe-integer range. Tested with Zod 4; other Standard Schema validators (Valibot, ArkType) are untested.
Every snippet on this page is compiled or run against docuconf-js's main branch in CI. Facts checked 2026-10-08.