Typed Environment Validation At Startup

A startup validator that converts raw environment strings into typed values once and fails with a complete list of problems.

TypescriptConfig Management

Code

export interface EnvSpec<T> {
  readonly name: string;
  readonly parse: (raw: string) => T;
  readonly required?: boolean;
  readonly defaultValue?: T;
  readonly min?: number;
  readonly max?: number;
}

export class EnvValidationError extends Error {
  readonly problems: string[];
  constructor(problems: string[]) {
    super("invalid environment:\n  " + problems.join("\n  "));
    this.name = "EnvValidationError";
    this.problems = problems;
  }
}

function boundsProblem<T>(spec: EnvSpec<T>, value: T): string | null {
  if (typeof value !== "number") return null;
  const n = value as number;
  if (spec.min !== undefined && n < spec.min) return spec.name + " below minimum " + spec.min;
  if (spec.max !== undefined && n > spec.max) return spec.name + " above maximum " + spec.max;
  return null;
}

/**
 * Parse declared environment variables once, aggregating every problem.
 * A default applies only when the variable is absent or empty and not required.
 */
export function loadEnv<T extends Record<string, unknown>>(
  specs: readonly EnvSpec<unknown>[],
  env: Record<string, string | undefined> = process.env,
): T {
  const config: Record<string, unknown> = {};
  const problems: string[] = [];

  for (const spec of specs) {
    const raw = env[spec.name];
    if (raw === undefined || raw === "") {
      if (spec.defaultValue !== undefined) {
        config[spec.name] = spec.defaultValue;
        continue;
      }
      if (spec.required !== false) {
        problems.push(spec.name + " is required but missing");
      }
      continue;
    }
    let parsed: unknown;
    try {
      parsed = spec.parse(raw);
    } catch {
      problems.push(spec.name + " could not be parsed from " + JSON.stringify(raw));
      continue;
    }
    // parseInt 等 parser 对无效输入返回 NaN 而不抛错,必须显式判定为解析失败
    if (typeof parsed === "number" && Number.isNaN(parsed)) {
      problems.push(spec.name + " could not be parsed from " + JSON.stringify(raw));
      continue;
    }
    const problem = boundsProblem(spec, parsed);
    if (problem) {
      problems.push(problem);
      continue;
    }
    config[spec.name] = parsed;
  }

  if (problems.length > 0) throw new EnvValidationError(problems);
  return config as T;
}