Skip to content

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:

FunctionWhat 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']) }))
ParserReads
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 })