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 forNumeric 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 mappingString 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 stagingThe 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 —envisEnvironment, not a string. UseEnvironment.Stagingconsistently. (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 supportconst enum) cost more than the savings. Use plainenumor — better — a literal union.
🎯 Practice task
Rewrite a config in both styles. 20-30 minutes.
- In your
ts-for-qa/srcfolder, createconfig-enum.ts. - Define
enum Environment,enum Browser, andenum Prioritywith explicit string values. - Define
interface TestRunConfigusing the three enums plus aretries: number. - Write
function getBaseUrl(env: Environment): stringwith aswitchover every enum member. - Build a
config: TestRunConfigvalue and callgetBaseUrl(config.env). Run withnpx ts-node src/config-enum.ts. - Now create
config-union.tsand rewrite the same module using literal-string unions instead of enums. Compare the two files line by line. - 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.
- 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.