DEV Community

Cover image for Implementing the Newtype Pattern with Zod: Enhancing Type Safety in TypeScript
tumf
tumf

Posted on • Originally published at blog.tumf.dev

Implementing the Newtype Pattern with Zod: Enhancing Type Safety in TypeScript

Originally published on 2026-01-22
Original article (Japanese): Zodで実装するNewtype Pattern: TypeScriptに欠けている型安全性を補う

The type system of TypeScript is based on structural typing. This means that different types are treated as the same if their structures are identical. As a result, bugs can occur when values that should be distinguished are mistakenly confused.

Using branded types from Zod can solve this problem. This is a practical approach to implementing the Newtype Pattern in TypeScript, which assigns "meaningfully different types" to the same primitive type to prevent mix-ups.

In this article, we will introduce the basics of branded types and their practical usage with examples.

The Problem of Structural Typing in TypeScript

In TypeScript, the following code passes without errors:

type UserId = string;
type PostId = string;

function getUser(userId: UserId) {
  // Fetch user
}

function getPost(postId: PostId) {
  // Fetch post
}

const userId: UserId = "user_123";
const postId: PostId = "post_456";

getUser(postId); // ❌ Ideally, this should throw an error, but it passes
getPost(userId); // ❌ This also passes
Enter fullscreen mode Exit fullscreen mode

Since both UserId and PostId are of type string, TypeScript cannot distinguish between them. This could lead to fetching the wrong records from the database at runtime.

Solution with Zod's Branded Types

By using Zod's .brand<> method, we can "stamp" the types to distinguish them.

import { z } from "zod";

const UserIdSchema = z.string().brand<"UserId">();
const PostIdSchema = z.string().brand<"PostId">();

type UserId = z.infer<typeof UserIdSchema>;
type PostId = z.infer<typeof PostIdSchema>;

function getUser(userId: UserId) {
  // Fetch user
}

function getPost(postId: PostId) {
  // Fetch post
}

const userId = UserIdSchema.parse("user_123");
const postId = PostIdSchema.parse("post_456");

// @ts-expect-error PostId is not assignable to UserId
getUser(postId);

// @ts-expect-error UserId is not assignable to PostId
getPost(userId);
Enter fullscreen mode Exit fullscreen mode

Internal Structure of Types

Branded types internally look like this:

type UserId = string & z.$brand<"UserId">;
type PostId = string & z.$brand<"PostId">;
Enter fullscreen mode Exit fullscreen mode

A special type z.$brand<"UserId"> is added as an intersection type (&), allowing the same string to be treated as different types.

Practical Example 1: Distinguishing Email Addresses and Usernames

When validating forms, you may want to distinguish between email addresses and usernames:

const EmailSchema = z.email().brand<"Email">();
const UsernameSchema = z.string().min(3).max(20).brand<"Username">();

type Email = z.infer<typeof EmailSchema>;
type Username = z.infer<typeof UsernameSchema>;

function sendEmail(to: Email, subject: string) {
  // Email sending logic
}

function createUser(username: Username) {
  // User creation logic
}

const email = EmailSchema.parse("user@example.com");
const username = UsernameSchema.parse("john_doe");

sendEmail(email, "Welcome!"); // ✅ OK
// @ts-expect-error Username is not assignable to Email
sendEmail(username, "Welcome!");
Enter fullscreen mode Exit fullscreen mode

Practical Example 2: Distinguishing Currencies

When dealing with amounts, mixing up currencies can lead to serious issues.

const USDSchema = z.number().positive().brand<"USD">();
const JPYSchema = z.number().int().positive().brand<"JPY">();

type USD = z.infer<typeof USDSchema>;
type JPY = z.infer<typeof JPYSchema>;

function chargeUSD(amount: USD) {
  console.log(`Charging $${amount}`);
}

function chargeJPY(amount: JPY) {
  console.log(`Charging ¥${amount}`);
}

const usd = USDSchema.parse(100.50);
const jpy = JPYSchema.parse(10000);

chargeUSD(usd); // ✅ OK
// @ts-expect-error JPY is not assignable to USD
chargeUSD(jpy);
Enter fullscreen mode Exit fullscreen mode

Practical Example 3: Validating API Responses

An example of validating data obtained from an external API and treating it as a branded type:

const UserResponseSchema = z.object({
  id: z.string().brand<"UserId">(),
  email: z.email().brand<"Email">(),
  createdAt: z.iso.datetime({ offset: true }).brand<"ISODateTime">(),
});

type UserResponse = z.infer<typeof UserResponseSchema>;

async function fetchUser(userId: string): Promise<UserResponse> {
  const response = await fetch(`/api/users/${userId}`);
  const data = await response.json();

  // Runtime validation + branding
  return UserResponseSchema.parse(data);
}

function displayUser(user: UserResponse) {
  console.log(`User ID: ${user.id}`);
  console.log(`Email: ${user.email}`);
}

const user = await fetchUser("user_123");
displayUser(user); // ✅ Type safe
Enter fullscreen mode Exit fullscreen mode

Notes on Branded Types

1. No Impact on Runtime

Branded types function only for static type checking. At runtime, they are treated as regular values.

const userId = UserIdSchema.parse("user_123");
console.log(typeof userId); // => "string"
Enter fullscreen mode Exit fullscreen mode

2. Parsing is Mandatory

To obtain a branded type, you must execute .parse() or .safeParse() with a Zod schema.

const userId: UserId = "user_123"; // ❌ Error: string cannot be assigned to UserId
const userId = UserIdSchema.parse("user_123"); // ✅ OK
Enter fullscreen mode Exit fullscreen mode

3. Control Over Input and Output Directions

Starting from Zod 4.2, you can specify the direction of the brand.

// Default: Brand is applied only to output
z.string().brand<"UserId">();
z.string().brand<"UserId", "in">(); // Same (Zod 4.2+)

// Brand is applied only to input
z.string().brand<"UserId", "out">();

// Brand is applied to both input and output
z.string().brand<"UserId", "inout">();
Enter fullscreen mode Exit fullscreen mode

Comparison with Pydantic

Python also has similar functionality in Pydantic.

Pydantic (Python):

from pydantic import BaseModel, Field
from typing import Annotated

UserId = Annotated[str, Field(pattern=r'^user_\d+$')]

class User(BaseModel):
    id: UserId
Enter fullscreen mode Exit fullscreen mode

Zod (TypeScript):

const UserIdSchema = z.string().regex(/^user_\d+$/).brand<"UserId">();
type UserId = z.infer<typeof UserIdSchema>;
Enter fullscreen mode Exit fullscreen mode

Both share the philosophy of "inferring types from schema definitions," but Zod's branded types are specifically focused on type-level distinctions.

Conclusion

By using Zod's branded types, you can address the weaknesses of TypeScript's structural typing and gain the following benefits:

  • Prevent Type Mix-ups: Avoid bugs caused by confusing UserId and PostId.
  • Express Domain Models Clearly: Clearly express domain-specific types like currency, email addresses, and timestamps.
  • Combine Runtime Validation with Type Safety: Write safe code by combining Zod's validation with type inference.

Especially in large projects or applications that frequently interact with external APIs, considering the introduction of branded types is worthwhile.

Reference Links

Top comments (0)