The environment
By the end of this guide you will read environment variables as a plain value, combine and filter
them, and parse them into the typed settings your program runs with, in Node, in a page and in a
test. @wtf/platform/env treats the environment as a value: Env maps each name to a string,
or to nothing, as process.env does. Code that needs the environment takes it as a parameter and
never reads process.env or globalThis itself. Your program reads its environment once, where it
starts, and passes it on; a test passes a plain object.
Pass the environment as a value
Env is Readonly<Record<string, string | undefined>>. In Node, nodeEnv() from
@wtf/platform-node/env gives you process.env. In a page, domEnv({ document }) from
@wtf/platform-browser/env reads what the page carries. A plain { PORT: '8080' } is one too.
import type { Env } from '@wtf/platform/env'
/** The origin of the API, of the environment it is given. */export const apiOrigin = (env: Env): string => env.API_ORIGIN ?? 'http://localhost:3000'
apiOrigin({ API_ORIGIN: 'https://example.com' })Combine and filter variables
Each of these functions returns a new environment, a frozen copy of the variables it keeps:
| Function | What it returns |
|---|---|
pick(env, names) | only the variables you name |
prefixed(env, prefix, { strip }) | the variables whose names start with prefix, without it with strip |
omit(env, names) | every variable except those you name |
layer(...envs) | all of them merged, a later environment’s value winning over an earlier |
layer(env) with one environment takes a snapshot of it. A name that a later environment does not
set keeps the earlier value; use omit to leave a variable out.
They are plain functions, not stream stages. You read an environment once, as the program starts,
and all at once; a stream would only wrap it and make it asynchronous. They compose as function
calls do: parse(layer(defaults, prefixed(env, 'APP_', { strip: true })), shape).
Parse variables into settings
parse(env, spec) turns an environment into the typed values your program runs with, in one
call. spec is either a shape or a Standard Schema of the whole environment. A shape maps each
name to a parser: one from ./env, one of your own, or a Standard Schema of the value. A variable
that is not set reaches its parser as undefined.
import { boolean, integer, list, optional, parse, url } from '@wtf/platform/env'import { nodeEnv } from '@wtf/platform-node/env'import { z } from 'zod'
const { PORT, DEBUG, API_URL, ORIGINS } = parse(nodeEnv(), { PORT: integer({ default: 3000 }), DEBUG: boolean({ default: false }), API_URL: optional(url()), ORIGINS: list({ default: [] })})
const { LEVEL } = parse(nodeEnv(), z.object({ LEVEL: z.enum(['info', 'debug']) }))| Parser | Reads |
|---|---|
string() | the value as it is |
number() | a finite number: 3000, 0.5, 1e3 |
integer() | a safe integer: 8080 |
boolean() | true, 1, yes, on, and false, 0, no, off, in any case |
url() | an absolute URL, as a URL |
list() | items split by separator (,) and trimmed, each read by the of parser |
optional(p) | what p reads, or undefined when the variable is not set |
Each parser takes a default for a variable that is not set. An empty value still counts as a
value: PORT= is not a number.
A Standard Schema of your own reads a value too. This one takes info or debug:
import { parse } from '@wtf/platform/env'
const level = { '~standard': { version: 1, vendor: 'example', validate: (value: unknown) => value === 'info' || value === 'debug' ? { value } : { issues: [{ message: 'not info or debug' }] } }} as const
const { LEVEL } = parse({ LEVEL: 'debug' }, { LEVEL: level })Handle errors
parse reports every problem at once. It throws a BadArgument from @wtf/platform/errors,
with module Env and method parse, listing each variable that is missing or not valid. Its
description has one line for each, such as PORT is not set. or
PORT is not valid: not an integer., and its cause lists an EnvIssue for each (variable,
missing, reason). Each names the variable and never its value, which may be a secret. A
Standard Schema that validates asynchronously also throws a BadArgument. A page’s #env block
that is not a JSON object of strings throws a SystemError with the reason InvalidData.
Read the environment in Node
nodeEnv(), from @wtf/platform-node/env, is process.env itself: a live view, so each read
sees the variable as it is now, and on Windows a name matches in any case.
nodeEnv({ snapshot: true }) returns a frozen copy of it as it is now. It should be the one place
your program reads process.env.
Read the environment in a page
domEnv({ document, from, id, prefix }), from @wtf/platform-browser/env, reads what the page
carries, from the document you give it, once:
<script type="application/json" id="env"> { "API_URL": "https://example.com" }</script><meta name="env:MODE" content="production" />from is script, meta, or both in order, where a later one’s value wins (['script', 'meta']
by default). id is the block’s id (env), and prefix starts each meta tag’s name (env:). A
value in the block is a string, or null for none. Anyone who loads the page can read what it
carries, so never put a secret there.
importMetaEnv(import.meta.env), from @wtf/platform-browser/env, keeps the string values of
Vite’s import.meta.env that you hand it. Vite gives a page’s code only the variables whose names
start with its envPrefix, VITE_ by default, plus MODE and BASE_URL. DEV, PROD and SSR
are booleans, so they are left out.
import { prefixed } from '@wtf/platform/env'import { importMetaEnv } from '@wtf/platform-browser/env'
// What Vite's client types declare, with `"types": ["vite/client"]`.declare global { interface ImportMeta { readonly env: Readonly<Record<string, unknown>> }}
const env = prefixed(importMetaEnv(import.meta.env), 'VITE_', { strip: true })