Skip to content
docuconf
docuconf on GitHub

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

Every docuconf SDK is a v0.1 alpha and none is on a package registry yet, so step 1 installs from the 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 zod

2.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.

src/env.ts
// 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.

src/server.ts
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.js

With 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.

Terminal
$ 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 set

5.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.

src/env.test.ts
// 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.ts

6.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
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

Every snippet on this page is compiled or run against docuconf-js's main branch in CI. Facts checked 2026-10-08.