Back to patterns
Turn raw strings into domain values

Branded Types / Newtype Pattern

This is the TypeScript version of the classic newtype pattern. You take a primitive like string, give it a more specific domain meaning, and stop treating every raw string as interchangeable. That works well for UserID, but also for usernames, display names, slugs, emails, and many other values that deserve more structure than a plain primitive.

user-id.ts
declare const idTag: unique symbol;

/**
 * User identifier.
 */
export type UserID = string & { readonly [idTag]: true };

/**
 * User identifier helpers.
 */
export const UserID = (() => {
	const uuidV7Regex =
		/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

	return {
		/**
		 * Creates a new valid user identifier.
		 */
		new(): UserID {
			return Bun.randomUUIDv7() as UserID;
		},

		/**
		 * Checks whether the provided string is a valid `UserID`.
		 */
		is(value: string): value is UserID {
			return uuidV7Regex.test(value);
		},

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

When to reach for this pattern

Use it when several values are all primitives at runtime, but mean different things in your domain. IDs are the obvious example, but the same pattern also works for usernames, display names, slugs, email addresses, or any other string that should not be treated as just “some string”.

If you have ever passed the wrong value into a function and TypeScript could not help because everything was typed as string, this pattern is a very good fit.

Example

Here are the three most common entry points. new() creates a valid value inside your own system and does not fail, because it only ever produces a valid UserID. is() is a guard function, so it validates and narrows the type at the same time. parse() is useful at boundaries where invalid input should fail immediately, which also means it can throw.

demo.ts
function loadUser(userID: UserID) {
	// ...
}

const createdUserID = UserID.new();
loadUser(createdUserID);

const maybeUserID = params.userID;
if (UserID.is(maybeUserID)) {
	loadUser(maybeUserID); // narrowed by the guard function
}

try {
	const parsedUserID = UserID.parse(body.userID);
	loadUser(parsedUserID);
} catch (error) {
	console.error(error);
}

TLDR;

Wrap a primitive in a branded type, then expose one small helper object for creation, validation, and parsing. That gives you the benefits of a domain type without turning simple values into heavy objects or classes.

If you are newer to TypeScript, the simple mental model is: keep the runtime value boring, but make the type system more specific.

What the branded type is doing

The type UserID = string & { readonly [idTag]: true } is still a string, but not just any string. The extra branded part makes TypeScript treat it as a separate type that cannot be mixed with plain strings by accident.

That means a function that expects UserID becomes more honest. It no longer says “give me any string”. It says “give me a string that has already been recognized as a valid user id”.

Why the helper object matters

The type alone is not enough. You also need a clear place where these values come from. That is why the companion object is so useful. UserID.new() creates a fresh valid id, UserID.is(value) acts as a guard function for raw strings, and UserID.parse(value) turns untrusted input into a real UserID or throws.

Without that helper object, teams often end up scattering regexes, type assertions, and ad-hoc parsing logic all over the codebase. This pattern keeps the rules in one place.

A very nice detail is is() as a guard function. Because it is written as a type predicate, value is UserID, it validates at runtime and narrows the type for TypeScript at the same time.

The architectural payoff

The biggest win is architectural, not cosmetic. Raw input enters at the boundaries of your system as plain string. There you either validate it and turn it into a UserID, or you reject it immediately.

Once that boundary is crossed, the rest of the application can work with the stronger type. In normal application flow, there is no longer a concept of an “invalid UserID” floating around in your domain layer, because invalid values never become UserID in the first place.

That changes the shape of the whole codebase. Validation becomes concentrated near the edges, while the inside of the system gets simpler because it can assume stronger invariants.

Why this feels good in practice

It improves safety without making the runtime model heavy. You do not need a class instance just to represent an ID. You still pass plain strings around at runtime, which keeps serialization, logging, and storage simple.

At the same time, the call site becomes more self-explanatory. Seeing UserID.parse(params.userID) tells the reader exactly what is happening: raw input is being validated and turned into a trusted identifier before use.

This also makes wrong calls harder to write. If a function expects UserID, you cannot casually hand it some unrelated string and hope for the best. The same advantage applies to other branded values like usernames or display names.