Back to patterns
Enum values as real domain types

Branded Better Enum Type

This pattern combines ideas from Branded Types / Newtype Pattern and A Better Enum Alternative. The result is an enum-like value set that still behaves like a real domain type, with parsing, guard functions, and a trusted default.

locale.ts
interface LocaleValues {
	/** Follow the user's environment or browser preference. */
	Auto: "auto";
	/** German locale. */
	De: "de";
	/** English locale. */
	En: "en";
}

declare const localeTag: unique symbol;

const values = {
	Auto: "auto",
	De: "de",
	En: "en",
} as const satisfies LocaleValues;

type LocaleValue = (typeof values)[keyof typeof values];

/**
 * Locale value supported by the user settings model.
 */
export type Locale = LocaleValue & { readonly [localeTag]: true };

/**
 * Supported locale values for user-facing web settings.
 */
export const Locale = (() => {
	const locales = new Set<LocaleValue>(Object.values(values));

	return {
		...values,

		/**
		 * Default Locale.
		 */
		default: values.Auto as Locale,

		/**
		 * Checks whether the provided string is a valid `Locale`.
		 */
		is(value: string): value is Locale {
			return locales.has(value as LocaleValue);
		},

		/**
		 * Parses a string into a `Locale`.
		 *
		 * @throws {Error} If the provided value is not a valid `Locale`.
		 */
		parse(value: string): Locale {
			if (!this.is(value)) {
				throw new Error("Invalid locale");
			}
			return value;
		},
	};
})();

When to reach for this pattern

Use it when you have a closed set of allowed values, but those values should also be treated as a proper domain type instead of raw strings. Locale settings are a good example, but the same idea also works for themes, roles, feature modes, or other user-facing preferences.

This is especially useful when values come from untrusted boundaries such as query params, cookies, forms, or persisted config. You want enum-like ergonomics inside the app, but you also want a clear way to reject invalid input at the edges.

Example

The helper object covers the three common cases again: strict parsing, a guard-based conditional path, and a trusted default for fallback behavior.

demo.ts
const localeFromConfig = Locale.parse(input.locale);

const localeForSettings = Locale.is(query.locale)
	? query.locale
	: Locale.default;

renderSettingsPage({
	locale: localeForSettings,
});

TLDR;

First model the allowed values like a better enum. Then brand the result so it becomes a domain type instead of “just another string”.

This gives you explicit named values like Locale.Auto, but also parsing, type guards, and the guarantee that trusted application code only works with valid locale values.

What this combines

The A Better Enum Alternative pattern gives you named values and a clear runtime API. The Branded Types / Newtype Pattern pattern gives you stronger domain boundaries and safer parsing.

This pattern combines both. Locale.Auto, Locale.De, and Locale.En are easy to use like enum members, but the resulting values are still treated as a real domain type instead of plain strings.

Why branding still matters here

Without the brand, a locale would just be the union "auto" | "de" | "en". That is already helpful, but it still behaves like an ordinary string union throughout the codebase.

With the brand, Locale becomes a distinct domain value. That means you can make it clear which values have already been validated and which values are still raw input from outside the system.

The architectural payoff

The important consequence is the same as with branded IDs: once a value has become Locale, normal application code does not have to keep wondering whether it might still be invalid.

Validation stays at the boundaries. Inside the domain layer, you work with trusted values like Locale and Locale.default instead of raw strings from forms, query params, or persisted settings.