π‘οΈ Type-safe, runtime-validated, and nominally-typed primitives for TypeScript, built on Zod.
Refined types and branded types address a fundamental challenge in TypeScript development: the inability to distinguish between primitive types that represent different semantic concepts.
In TypeScript's structural type system:
-
Type Safety Gaps: TypeScript's primitive types (
string,number, etc.) don't prevent logical errors like passing an email to a URL parameter, confusing different string IDs, or mixing measurement units.// These are all just strings to TypeScript! function sendEmail(to: string, subject: string) { /* ... */ } // Nothing prevents these dangerous mistakes: sendEmail(subject, emailAddress); // Oops, parameters swapped sendEmail(userId, subject); // Oops, wrong string type
-
No Runtime Type Guarantees: TypeScript's static type system is erased at runtime, allowing invalid values to pass through if they match the expected structure.
-
Lack of Nominal Typing: TypeScript cannot natively differentiate between structurally identical types with different semantic meanings (email vs. username vs. password).
-
Validation Fragmentation: Developers end up duplicating validation logic across the codebase, often inconsistently.
-
Refined Types: Types with associated runtime validations that ensure values satisfy specific constraints or predicates.
-
Branded Types: Types that are "branded" with a unique tag to make them nominally distinct despite having the same underlying structure.
// Without branding: type UserId = string; type PostId = string; // TypeScript sees these as identical: function getPost(id: PostId) { /* ... */ } const userId: UserId = "user-123"; getPost(userId); // No error! π±
Developers have traditionally created branded types using a variety of techniques, each with significant drawbacks:
// Approach 1: Using interfaces
interface EmailBrand {
readonly __brand: unique symbol;
}
type Email = string & EmailBrand;
// Problems:
// 1. No runtime validation
// 2. Easy to bypass with type casting
const email = "invalid-email" as Email; // Compiles fine! π±// Approach 2: Using symbols
type Brand<T, B> = T & { __brand: B };
type Email = Brand<string, "Email">;
// Creation function with no validation
const asEmail = (str: string): Email => str as Email;
// Problems:
// 1. No validation at runtime
// 2. Easy to create invalid values
const email = asEmail("not-valid"); // No error!// Approach 3: Class wrapper
class Email {
private __brand: undefined;
constructor(public readonly value: string) {
// Maybe some validation, but must use try/catch
if (!value.includes("@")) throw new Error("Invalid email");
}
}
// Problems:
// 1. Boxing/unboxing overhead
// 2. Exception-based validation
// 3. Need to access .value property
try {
const email = new Email("user@example"); // Runtime exception
sendEmail(email.value); // Must extract .value
} catch (e) {
// Error handling with try/catch everywhere
}All these approaches suffer from common problems:
- No unified validation strategy: Ad-hoc validation if any
- Exception-based error handling: Try/catch blocks everywhere
- Manual type casting: Prone to mistakes or intentional bypassing
- No standardized error types: Difficult to handle errors consistently
- No composition model: Difficult to compose branded types
- Poor developer experience: Verbose and error-prone
@carbonteq/refined-type elegantly combines both approaches to create a
comprehensive solution:
-
Zod Integration: Leverages Zod's powerful validation capabilities for comprehensive runtime checks with excellent error reporting.
-
Nominal Type Safety: Uses TypeScript's branded types to create true nominal distinctions between types.
-
Result-based API: Implements a functional
Result<T, E>type from@carbonteq/fpthat eliminates exceptions and enables elegant error handling. -
Developer Experience: Provides intuitive helper types and utilities for seamless integration into any TypeScript project.
-
Custom Error Handling: Supports domain-specific error transformations to create meaningful error messages for your business logic.
-
Zero Runtime Overhead: No performance penalties for primitive operations after validation.
- β Type Safety: Full compile-time and runtime type safety
- π·οΈ Nominal Typing: True nominal type distinctions in TypeScript
- π Functional API: Uses
Resulttype for elegant error handling - π§© Composable: Chain and compose validations easily
- π οΈ Customizable: Extend with your own validators and error types
- π¦ Lightweight: Minimal runtime footprint
import { createRefinedType } from "@carbonteq/refined-type";
import * as z from "zod/v4";
// Create a refined Email type with built-in validation
const Email = createRefinedType("Email", z.string().email());
type Email = typeof Email.$infer; // Get the branded type
// === Type Safety ===
// These won't compile:
// const email1: Email = "invalid@email"; // Error: Type 'string' is not assignable to type 'Email'
// const email2: Email = "[email protected]"; // Error: Must use .create() to construct
// === Runtime Validation ===
// Safe creation with validation
const emailResult = Email.create("[email protected]");
if (emailResult.isOk()) {
const validEmail = emailResult.unwrap();
sendEmail(validEmail); // validEmail is branded and type-safe
// Functions requiring Email type will only accept valid emails
function sendEmail(to: Email) {
/* ... */
}
// This won't compile - string is not assignable to Email
// sendEmail("[email protected]"); // Error!
}
// === Error Handling ===
const invalidResult = Email.create("not-an-email");
if (invalidResult.isErr()) {
// Structured error handling
console.error(invalidResult.unwrapErr().message);
// "Invalid email"
}import { Result } from "@carbonteq/fp";
import {
RefinedValidationError,
createRefinedType,
} from "@carbonteq/refined-type";
import * as z from "zod/v4";
// Define domain-specific error type
class InvalidUserIdError extends RefinedValidationError {
constructor(data: unknown, err: z.ZodError) {
super(err);
this.name = "InvalidUserIdError";
this.message = `Invalid user ID: ${data}. Must be a valid UUID.`;
}
}
// Create a UserId type with custom error handling
const UserId = createRefinedType(
"UserId",
z.string().uuid(),
(data, err) => new InvalidUserIdError(data, err),
);
type UserId = typeof UserId.$infer;
// Usage in application code
function fetchUser(id: unknown): Result<User, InvalidUserIdError | ApiError> {
// Validate and create UserId first
const userIdResult = UserId.create(id);
if (userIdResult.isErr()) {
return Result.Err(userIdResult.unwrapErr());
}
const userId = userIdResult.unwrap();
return api.getUser(userId);
}import { Result } from "@carbonteq/fp";
import { type Unbrand, createRefinedType } from "@carbonteq/refined-type";
import * as z from "zod/v4";
// Define a refined number type for positive numbers
const PositiveNumber = createRefinedType(
"PositiveNumber",
z.number().positive(),
);
type PositiveNumber = typeof PositiveNumber.$infer;
// Get the underlying primitive type (number in this case)
type UnbrandedNumber = Unbrand<PositiveNumber>; // Plain number
// Converting back to primitive when needed
function calculateTotal(
price: PositiveNumber,
quantity: PositiveNumber,
): number {
// Access the primitive values
const priceValue = PositiveNumber.primitive(price);
const quantityValue = PositiveNumber.primitive(quantity);
return priceValue * quantityValue;
}
// Create safe numeric types
const priceResult = PositiveNumber.create(29.99);
const quantityResult = PositiveNumber.create(3);
// Use Result combinators for elegant handling
const totalResult = Result.CombineResults([priceResult, quantityResult]).map(
([price, quantity]) => calculateTotal(price, quantity),
);
// Safe unwrapping
const total = totalResult.unwrapOr(0);import { Result } from "@carbonteq/fp";
import { createRefinedType } from "@carbonteq/refined-type";
import * as z from "zod/v4";
// Create domain-specific types
const Name = createRefinedType("Name", z.string().min(2).max(50));
const Age = createRefinedType("Age", z.number().int().min(0).max(120));
const Email = createRefinedType("Email", z.string().email());
// Compose them into a schema
const personSchema = z.object({
name: Name,
age: Age,
email: Email,
});
// Define the type using our refined types
type Person = {
name: typeof Name;
age: typeof Age;
email: typeof Email;
};
// Validate an entire object with multiple refined types
function createPerson(data: unknown): Result<Person, Error> {
const result = personSchema.safeParse(data);
if (!result.success) {
return Result.Err(new Error("Invalid person data"));
}
return Result.Ok(result.data as Person);
}- @carbonteq/fp and zod are dependencies thus need to be installed
# NPM
npm install @carbonteq/refined-type @carbonteq/fp zod
# Yarn
yarn add @carbonteq/refined-type @carbonteq/fp zod
# PNPM
pnpm add @carbonteq/refined-type @carbonteq/fp zod- Node.js >= 18
- TypeScript >= 4.9
- Zod >= 3.25.0
- @carbonteq/fp >= 0.7.0
MIT