Typed env and config
Shape the config. Narrow the env. Fail before the app runs.
<!-- hal:authoritative:yaml -->
*Name the config shape. Mark what must not mutate. Read environment strings as string | undefined, then narrow them into that shape before the rest of the program runs.*
§I — Frame
Duha Primary TypeScript session 7. Topics #14 (a teammate remap): typed env and config. Topics #10 async shipped 2026-09-21. Bun-centric rows 5–8 and syllabus #11–13 (bun:sqlite, bundling, Node interop) stay deferred at this clock. Duha Primary wants type depth, not a Bun survey.
Done-criteria: Can define a readonly Config type, load process.env into it with required-key checks, and freeze literals with as const where the set of values is closed.
Primary cite: Cherny Programming TypeScript on disk (readonly, unknown, as const, Record, Options-shaped config). Secondary: Handbook Utility Types for Readonly / Record.
Home: . Do not dump into Polyglot-Dev/Web/.
§II — Config is a shape, not a bag of strings
Cherny types configuration as an object with known fields. His Options example for an API constructor is the pattern:
type Options = {
baseURL: string
cacheSize?: number
tier?: "prod" | "dev"
}
class API {
constructor(private options: Options) {}
}
Excess property checking catches typos at the call site (tier: "production" fails if only "prod" | "dev" is allowed). That is the point of a named config type: the rest of the program consumes Options, not ad hoc string maps.
Mark fields that must not change after construction with readonly (Cherny Ch.3 — "const for object properties"):
type Config = {
readonly baseURL: string
readonly port: number
readonly tier: "prod" | "dev"
}
const config: Config = {
baseURL: "https://api.example.com",
port: 8080,
tier: "prod",
}
// config.port = 9000 // Error: read-only property
Readonly<Config> (Handbook / Cherny's Readonly utility) applies the same rule to every field of an existing type. Prefer readonly on the fields you own when declaring the type; use Readonly<T> when wrapping a type you did not author.
§III — as const and closed value sets
Ordinary object literals widen. Cherny's const assertion opts out:
let a = { x: 3 } // { x: number }
let c = { x: 3 } as const // { readonly x: 3 }
as const narrows literals and recursively marks members readonly. Use it for tables of allowed values and for default config objects whose keys and literal tiers must stay exact:
const DEFAULTS = {
tier: "dev",
cacheSize: 128,
} as const
type Tier = typeof DEFAULTS.tier // "dev"
Record<K, T> (Cherny Ch.6) types a map where every key in K must appear:
type Tier = "prod" | "dev"
const ports: Record<Tier, number> = {
prod: 443,
dev: 3000,
}
Omit a key and the checker fails. That is the right tool for "every environment name has a port," not for open-ended process.env bags.
§IV — Environment values are string | undefined
In Node typings (@types/node) and Bun's compatible surface, process.env.FOO is string | undefined. It is not string. Treating it as always present is a type lie that becomes a runtime crash.
Load once. Narrow. Fail fast:
function requireEnv(key: string): string {
const value = process.env[key]
if (value === undefined || value === "") {
throw new Error(`Missing required env: ${key}`)
}
return value
}
function loadConfig(): Config {
const tierRaw = requireEnv("APP_TIER")
if (tierRaw !== "prod" && tierRaw !== "dev") {
throw new Error(`APP_TIER must be prod|dev, got ${tierRaw}`)
}
const port = Number(requireEnv("PORT"))
if (!Number.isFinite(port)) {
throw new Error("PORT must be a number")
}
return {
baseURL: requireEnv("BASE_URL"),
port,
tier: tierRaw,
}
}
Optional keys stay string | undefined until you supply a default. Do not use non-null assertions (!) to silence the checker: that deletes the information Cherny's unknown advice protects. When a value arrives as unknown (JSON file, parsed YAML), narrow with checks before assigning into Config.
Parse numbers and booleans explicitly. Env is always stringly; Number("8080") and value === "1" are application policy, not type inference.
§V — One complete proof
- Declare a
Config(orOptions) type with at least onereadonlyfield and one string-literal union. - Build a small
as constdefaults object and derive a literal type withtypeof. - Write
requireEnv(or equivalent) that returnsstringonly after checkingundefined/ empty. - Implement
loadConfig(): Configthat maps env keys into the typed shape and rejects badtier/ non-numericport. - Show one
Record<"prod" | "dev", number>(or similar) that errors if a key is missing.
When those five hold, Topics #14's selected depth is done.
§VI — Closing
Config is a typed shape. readonly and as const lock what must not drift. Environment variables enter as optional strings; your loader is the gate that turns them into Config or stops the process. Cherny supplies readonly, as const, Record, and Options-shaped objects; the applied half is fail-fast env loading. Bun #11–13 stay deferred. Next Primary TypeScript depth follows the syllabus after #14 once a teammate seats it.
Done-criteria: readonly Config + fail-fast env load + as const where literals are closed.