Hedronite Lesson · Polyglot-Dev / TypeScript · Tue 2026-09-22

Typed env and config

Shape the config. Narrow the env. Fail before the app runs.

Lesson Class: Duha (TypeScript Primary track)
Focus: Config shape · readonly · as const · process.env string|undefined · fail-fast load · Record
Code Blocks: clean blocks, explanation in prose
Done-criteria: readonly Config + env load + as const literals
Grounding: Cherny readonly/as const/Record/Options · Handbook Utility Types · applied env
The shape
Config is a named type, not a string bag.
The lock
readonly and as const keep settled values settled.
The gate
Env is string | undefined until a loader narrows it.
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

  1. Declare a Config (or Options) type with at least one readonly field and one string-literal union.
  2. Build a small as const defaults object and derive a literal type with typeof.
  3. Write requireEnv (or equivalent) that returns string only after checking undefined / empty.
  4. Implement loadConfig(): Config that maps env keys into the typed shape and rejects bad tier / non-numeric port.
  5. 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.

Related