Enums for Test Configuration

7 min read

You've already used closed sets of values — "dev" | "staging" | "production", "chromium" | "firefox" | "webkit" — through literal-string unions. TypeScript has a second tool for this: the enum. Enums are the older, more class-like way to express "one of these named constants." This lesson covers when to reach for them, why string enums are the right default, and why most modern QA codebases reach for unions first.

What an enum is

An enum is a named group of constants:

enum Environment {
  Dev = "dev",
  Staging = "staging",
  Production = "production",
}
 
enum Browser {
  Chrome = "chromium",
  Firefox = "firefox",
  Safari = "webkit",
}
 
const env: Environment = Environment.Staging;
const browser: Browser = Browser.Chrome;

Environment.Staging is a value at runtime. The enum compiles to a real JavaScript object — unlike most TypeScript constructs, enums exist in the compiled output. That's the first thing that makes them different from a union.

String enums vs numeric enums — always use strings

Enums come in two flavours. The wrong one gets you into trouble:

// ❌ Numeric — implicit values, confusing reverse mappings
enum Priority {
  Low,        // 0
  Medium,     // 1
  High,       // 2
}
 
console.log(Priority.Low);      // 0
console.log(Priority[0]);        // "Low"  ← reverse mapping you didn't ask for

Numeric enums auto-assign integers. The reverse mapping (Priority[0] === "Low") is rarely useful and frequently surprising — JSON serialisation, log messages, and equality checks all behave inconsistently between the number and the string form.

// ✅ String — explicit values, no surprises
enum Priority {
  Low = "low",
  Medium = "medium",
  High = "high",
}
 
console.log(Priority.Low);     // "low"
console.log(Priority["low"]);  // undefined — no reverse mapping

String enums are predictable, JSON-friendly, and self-explanatory in logs. Always use string enums for QA code. The few cases where numeric enums make sense (bit flags) almost never come up in test automation.

Using enums in test config

function getBaseUrl(env: Environment): string {
  switch (env) {
    case Environment.Dev:        return "https://dev.myapp.com";
    case Environment.Staging:    return "https://staging.myapp.com";
    case Environment.Production: return "https://myapp.com";
  }
}
 
const url = getBaseUrl(Environment.Staging);

The compiler enforces the enum at every call site — a stray getBaseUrl("stagging") is a compile error because "stagging" isn't an Environment. This is exactly the protection literal unions give, with extra ceremony.

The same code, with a literal union

type Environment = "dev" | "staging" | "production";
 
function getBaseUrl(env: Environment): string {
  switch (env) {
    case "dev":        return "https://dev.myapp.com";
    case "staging":    return "https://staging.myapp.com";
    case "production": return "https://myapp.com";
  }
}
 
const url = getBaseUrl("staging");

Same compile-time guarantees. No runtime object. No Environment.Staging.toString() ceremony. Most modern test frameworks (Cypress, Playwright) lean on literal unions — their public APIs almost never expose enums.

When to use which

Enum vs literal union — same job, different ergonomics

enum Environment

  • Compiles to a runtime object — exists at runtime

  • Reach with Environment.Staging — explicit namespace

  • Centralizes the value set in one declaration

  • Use when: code already does enum-style — staying consistent matters

  • Adds runtime weight to your bundle

type Environment = "dev" | …

  • Compiles to nothing — purely a compile-time check

  • Use the strings directly — "staging"

  • Pairs naturally with API JSON, no conversion

  • Use when: starting fresh — what most modern TS projects pick

  • Cypress, Playwright, and most public APIs use this style

A practical rule for new QA code:

  • Default to literal-string unions. They're simpler, lighter, and match the style of the frameworks you'll integrate with.
  • Reach for enums only when you're working in a codebase that already uses them and consistency matters more than minimalism.

const enum — a small optimisation

A const enum is inlined by the compiler — Priority.Low becomes the literal value "low" in the compiled JavaScript, with no runtime object emitted. The benefit is a tiny bundle saving; the cost is a list of sharp edges (incompatibility with isolatedModules, problems when shipped across module boundaries, and outright disabled by some build setups).

const enum Priority {
  Low = "low",
  Medium = "medium",
  High = "high",
}
 
const p = Priority.Low;   // compiles to: const p = "low";

In QA code, const enum is rarely worth the complexity — the inlined output is almost identical to what you'd write with a literal union. Mention it because you'll see it in older codebases; reach for it only with a specific reason.

A QA-shaped config example

A test config that uses three enums for the values it cares about:

enum Environment {
  Dev = "dev",
  Staging = "staging",
  Production = "production",
}
 
enum Browser {
  Chrome = "chromium",
  Firefox = "firefox",
  Safari = "webkit",
}
 
enum Priority {
  P1 = "P1",
  P2 = "P2",
  P3 = "P3",
}
 
interface TestRunConfig {
  env: Environment;
  browser: Browser;
  minPriority: Priority;
  retries: number;
}
 
const config: TestRunConfig = {
  env: Environment.Staging,
  browser: Browser.Chrome,
  minPriority: Priority.P2,
  retries: 3,
};
 
console.log(`Running ${config.minPriority}+ tests on ${config.browser} against ${config.env}`);
// Running P2 on chromium against staging

The same config rewritten with literal unions is shorter — and reads almost identically:

type Environment = "dev" | "staging" | "production";
type Browser     = "chromium" | "firefox" | "webkit";
type Priority    = "P1" | "P2" | "P3";
 
interface TestRunConfig {
  env: Environment;
  browser: Browser;
  minPriority: Priority;
  retries: number;
}
 
const config: TestRunConfig = {
  env: "staging",
  browser: "chromium",
  minPriority: "P2",
  retries: 3,
};

Both are correct. Pick one and stay consistent across the project.

⚠️ Common mistakes

  • Defaulting to numeric enums. enum Priority { Low, Medium, High } is the old-school style and the one most tutorials still show. Auto-assigned integers and reverse mappings make logging, JSON serialisation, and equality checks unpredictable. Always assign explicit string values: enum Priority { Low = "low", ... }.
  • Mixing enum members and string literals. if (env === "staging") looks like it should work but doesn't — env is Environment, not a string. Use Environment.Staging consistently. (This is one of the friction points that pushes teams toward literal unions, where the comparison just works.)
  • Reaching for const enum "for performance." The micro-optimisation rarely matters in QA code, and the build-tool gotchas (especially with Babel and bundlers that don't support const enum) cost more than the savings. Use plain enum or — better — a literal union.

🎯 Practice task

Rewrite a config in both styles. 20-30 minutes.

  1. In your ts-for-qa/src folder, create config-enum.ts.
  2. Define enum Environment, enum Browser, and enum Priority with explicit string values.
  3. Define interface TestRunConfig using the three enums plus a retries: number.
  4. Write function getBaseUrl(env: Environment): string with a switch over every enum member.
  5. Build a config: TestRunConfig value and call getBaseUrl(config.env). Run with npx ts-node src/config-enum.ts.
  6. Now create config-union.ts and rewrite the same module using literal-string unions instead of enums. Compare the two files line by line.
  7. Trigger the comparison gotchas. After each, read the error and revert:
    • if (config.env === "staging") (with the enum version) — confirm it's a compile error.
    • if (config.env === Environment.Staging) (with the union version) — confirm it's also an error.
  8. Stretch: add a fourth enum value (Environment.Local) and walk every consumer pointing at the lines that need updating. Repeat with the union version. Note which felt easier — that's the practical reason most teams now lean on unions.

The next lesson moves from defining closed sets to navigating them at runtime — type guards and the narrowing patterns that make union-typed values safe to use.

// tip to track lessons you complete and pick up where you left off across devices.