Minimal brand toolkit for Zod.
@rzv/make-brand turns a Zod schema into a small toolkit for creating, validating, comparing, and composing branded values. It is useful when two values share the same runtime shape but must stay incompatible in TypeScript, such as UserId and OrderId, or Quantity and Price.
TypeScript is structurally typed, so two string values are interchangeable even if they represent different concepts. Branded types add nominal typing on top of your Zod schema while keeping the original runtime value.
- Runtime validation comes from Zod
- Type safety comes from the brand
- Values stay plain strings, numbers, or objects at runtime
zod is a peer dependency.
npm install @rzv/make-brand zodpnpm add @rzv/make-brand zodbun add @rzv/make-brand zodThe package is ESM-only.
import { z } from "zod";
import { makeBrand } from "@rzv/make-brand";
const UserIdBrand = makeBrand(z.string().uuid(), "UserId");
const OrderIdBrand = makeBrand(z.string().uuid(), "OrderId");
type UserId = z.infer<typeof UserIdBrand.schema>;
type OrderId = z.infer<typeof OrderIdBrand.schema>;
const userId = UserIdBrand.create("550e8400-e29b-41d4-a716-446655440000");
const orderId = OrderIdBrand.create("6ba7b810-9dad-11d1-80b4-00c04fd430c8");
function getUserById(id: UserId) {
return id;
}
getUserById(userId);
// getUserById(orderId); // Type errorAt runtime, userId is still just a string. The brand exists to improve type safety and schema composition.
const Brand = makeBrand(schema, "BrandName");makeBrand returns a BrandKit with the following fields:
| Field / method | Description |
|---|---|
schema |
Branded Zod schema you can reuse in larger schemas |
brandName |
Runtime brand name |
create(value) |
Parses and returns a branded value, throws on invalid input |
safeCreate(value) |
Returns a branded value or null |
matches(value) |
Runtime type guard based on schema validation |
ensure(value, message?) |
Assertion helper, throws Error on invalid input |
toPrimitive(value) |
Returns the underlying runtime value |
same(a, b, compareFn?) |
Equality check, default is strict equality |
compare(a, b, compareFn?) |
Sort helper, default uses < / > |
combine(...brands) |
Combines this brand with 1-4 additional brands or an array via intersection |
refineTo(next) |
Creates a new brand kit with the same brand name and a new schema |
pipeTo(next) |
Pipes the current branded schema into another Zod schema and returns a new brand kit |
import { z } from "zod";
import { makeBrand } from "@rzv/make-brand";
const UserIdBrand = makeBrand(z.string().uuid(), "UserId");
const strictUserId = UserIdBrand.create("550e8400-e29b-41d4-a716-446655440000");
const maybeUserId = UserIdBrand.safeCreate("not-a-uuid");
// maybeUserId === null
const input: unknown = "550e8400-e29b-41d4-a716-446655440000";
if (UserIdBrand.matches(input)) {
// input is narrowed to UserId here
}
UserIdBrand.ensure(input, "Invalid user id");
const rawValue = UserIdBrand.toPrimitive(strictUserId);
// rawValue is the same runtime string without branded typingUse schema whenever branded values need to participate in larger Zod objects.
import { z } from "zod";
import { makeBrand } from "@rzv/make-brand";
const UserIdBrand = makeBrand(z.string().uuid(), "UserId");
const QuantityBrand = makeBrand(z.number().int().positive(), "Quantity");
const OrderSchema = z.object({
id: UserIdBrand.schema,
quantity: QuantityBrand.schema,
});
const order = OrderSchema.parse({
id: "550e8400-e29b-41d4-a716-446655440000",
quantity: 5,
});same and compare are useful when you want a package-level convention for equality and sorting.
import { z } from "zod";
import { makeBrand } from "@rzv/make-brand";
const QuantityBrand = makeBrand(z.number().int().positive(), "Quantity");
const a = QuantityBrand.create(5);
const b = QuantityBrand.create(10);
const c = QuantityBrand.create(5);
QuantityBrand.same(a, c); // true
QuantityBrand.compare(a, b); // -1
const sorted = [b, a, c].sort(QuantityBrand.compare);You can also override the comparison for a single call:
QuantityBrand.compare(a, b, (x, y) => (x > y ? -1 : x < y ? 1 : 0));These two methods look similar, but they behave differently:
refineTo(next)keeps the brand name and replaces the schema withnextpipeTo(next)keeps the current branded schema as the first stage, then pipes it intonext
import { z } from "zod";
import { makeBrand } from "@rzv/make-brand";
const QuantityBrand = makeBrand(z.number().int().positive(), "Quantity");
const PercentageBrand = QuantityBrand.refineTo(z.number().int().min(0).max(100));
const UppercaseNameBrand = makeBrand(z.string().min(1), "DisplayName").pipeTo(
z.string().transform((value) => value.trim().toUpperCase()),
);
PercentageBrand.create(42);
UppercaseNameBrand.create(" alice "); // "ALICE"If you need refineTo to preserve previous constraints, include them again in the new schema explicitly.
Combines multiple brands into a single brand using Zod intersection. The resulting brand name joins all contributor names with &.
import { z } from "zod";
import { makeBrand } from "@rzv/make-brand";
const IntBrand = makeBrand(z.number().int(), "Int");
const PositiveBrand = makeBrand(z.number().positive(), "Positive");
const PositiveInt = IntBrand.combine(PositiveBrand);
// PositiveInt.brandName === "Int&Positive"
PositiveInt.create(5); // 5
PositiveInt.create(-1); // throws ZodError (not positive)
PositiveInt.create(1.5); // throws ZodError (not int)Up to 4 additional brands as arguments:
const MaxHundred = makeBrand(z.number().max(100), "MaxHundred");
const BoundedPositiveInt = IntBrand.combine(PositiveBrand, MaxHundred);
// BoundedPositiveInt.brandName === "Int&Positive&MaxHundred"Or pass an array:
const StrictInt = IntBrand.combine([PositiveBrand, MaxHundred]);
// StrictInt.brandName === "Int&Positive&MaxHundred"The combined schema is usable in z.object:
const Schema = z.object({ value: PositiveInt.schema });
const result = Schema.parse({ value: 42 });The package also exports the main utility types:
BrandKit— the full toolkit type returned bymakeBrandBrandSymbol— the brand marker{ readonly [z.$brand]: T }BrandedType<T, B>—T & BrandSymbol<B>BrandedSchema<TSchema, B>— a Zod schema that produces branded outputBrandCombineFn— overloaded function signature forcombineExtractBrandName<B>— extracts the brand name from aBrandKitJoinBrands<T>— joins a tuple of brand names with&
Example:
import type { BrandedType } from "@rzv/make-brand";
type UserId = BrandedType<string, "UserId">;npm install
npm test
npm run check
npm run buildRelease helpers:
npm run release:patch
npm run release:minor
npm run release:majorThis repository includes documentation files optimized for LLM consumption:
- llms.txt — compact index following the llms.txt convention, discoverable by Context7 and other LLM tools
- llms-full.txt — complete API reference in a single file for direct injection into LLM context
These files are automatically indexed by Context7 and compatible with any tool that follows the llms.txt standard.
MIT