Runtime validation for static types
—
Add custom validation logic and data transformation to existing runtypes. These utilities allow you to extend basic type validation with domain-specific rules, branding, and data parsing.
Adds custom validation logic to an existing runtype, narrowing its type.
/**
* Adds custom validation constraints to a runtype
* @param underlying - Base runtype to constrain
* @param constraint - Assertion function that validates and narrows the type
* @example Constraint(String, s => { if (s.length < 3) throw "Too short"; })
*/
function Constraint<T, U extends T>(
underlying: Runtype<T>,
constraint: (x: T) => asserts x is U
): ConstraintRuntype<T, U>;
interface ConstraintRuntype<T, U> extends Runtype<U> {
tag: "constraint";
underlying: Runtype<T>;
constraint: (x: T) => asserts x is U;
}Usage Examples:
import { Constraint, String, Number } from "runtypes";
// String length constraints
const MinLength = (min: number) =>
Constraint(String, (s: string): asserts s is string => {
if (s.length < min) throw `String must be at least ${min} characters`;
});
const Username = MinLength(3);
const username = Username.check("alice"); // "alice"
// Numeric range constraints
const PositiveNumber = Constraint(Number, (n: number): asserts n is number => {
if (n <= 0) throw "Number must be positive";
});
const Age = Constraint(Number, (n: number): asserts n is number => {
if (n < 0 || n > 150) throw "Age must be between 0 and 150";
});
const age = Age.check(25); // 25
// Email validation
const Email = Constraint(String, (s: string): asserts s is string => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(s)) throw "Invalid email format";
});
const email = Email.check("user@example.com"); // "user@example.com"Use the convenient built-in methods on runtypes for common constraints.
import { String, Number } from "runtypes";
// withConstraint - returns boolean or error message
const PositiveInteger = Number.withConstraint(n => n > 0 && Number.isInteger(n) || "Must be positive integer");
// withGuard - type predicate function
const NonEmptyString = String.withGuard((s): s is string => s.length > 0);
// withAssertion - assertion function
const ValidUrl = String.withAssertion((s): asserts s is string => {
try {
new URL(s);
} catch {
throw "Invalid URL";
}
});
// Usage
const count = PositiveInteger.check(5);
const name = NonEmptyString.check("Alice");
const url = ValidUrl.check("https://example.com");Adds nominal typing to create distinct types that are structurally identical but semantically different.
/**
* Adds a brand to create nominal typing
* @param brand - Brand identifier string
* @param entity - Underlying runtype to brand
* @example Brand("UserId", String).check("user_123") // branded string
*/
function Brand<B extends string, T>(brand: B, entity: Runtype<T>): BrandRuntype<B, T>;
interface BrandRuntype<B, T> extends Runtype<T & Brand<B>> {
tag: "brand";
brand: B;
entity: Runtype<T>;
}
// Or use the built-in method
declare module "runtypes" {
interface Runtype<T> {
withBrand<B extends string>(brand: B): BrandRuntype<B, T>;
}
}Usage Examples:
import { Brand, String, Number } from "runtypes";
// Create branded types
const UserId = Brand("UserId", String);
const ProductId = Brand("ProductId", String);
const Price = Brand("Price", Number);
type UserIdType = Static<typeof UserId>; // string & Brand<"UserId">
type ProductIdType = Static<typeof ProductId>; // string & Brand<"ProductId">
type PriceType = Static<typeof Price>; // number & Brand<"Price">
// Values are runtime identical but type-distinct
const userId = UserId.check("user_123");
const productId = ProductId.check("prod_456");
const price = Price.check(29.99);
// Type safety - these would be TypeScript errors:
// function processUser(id: UserIdType) { ... }
// processUser(productId); // Error: ProductId is not assignable to UserId
// Using withBrand method
const Email = String.withBrand("Email");
const Password = String.withBrand("Password");
// Combining with constraints
const ValidatedEmail = String
.withConstraint(s => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s) || "Invalid email")
.withBrand("Email");
const StrongPassword = String
.withConstraint(s => s.length >= 8 || "Password must be at least 8 characters")
.withConstraint(s => /[A-Z]/.test(s) || "Password must contain uppercase letter")
.withConstraint(s => /[0-9]/.test(s) || "Password must contain number")
.withBrand("StrongPassword");Transforms validated values using a custom parser function, enabling data conversion and normalization.
/**
* Adds custom parser to transform validated values
* @param underlying - Base runtype for validation
* @param parser - Function to transform the validated value
* @example Parser(String, s => s.toUpperCase()).parse("hello") // "HELLO"
*/
function Parser<T, U>(underlying: Runtype<T>, parser: (value: T) => U): ParserRuntype<T, U>;
interface ParserRuntype<T, U> extends Runtype<U> {
tag: "parser";
underlying: Runtype<T>;
parser: (value: T) => U;
}
// Or use the built-in method
declare module "runtypes" {
interface Runtype<T> {
withParser<U>(parser: (value: T) => U): ParserRuntype<T, U>;
}
}Usage Examples:
import { Parser, String, Number, Array, Object } from "runtypes";
// String transformations
const UpperCaseString = Parser(String, s => s.toUpperCase());
const TrimmedString = Parser(String, s => s.trim());
const name = UpperCaseString.parse("alice"); // "ALICE"
const cleaned = TrimmedString.parse(" hello "); // "hello"
// Numeric transformations
const IntegerFromString = Parser(String, s => parseInt(s, 10));
const AbsoluteNumber = Parser(Number, n => Math.abs(n));
const parsed = IntegerFromString.parse("123"); // 123 (number)
const absolute = AbsoluteNumber.parse(-42); // 42
// Date parsing
const DateFromString = Parser(String, s => new Date(s));
const ISODateFromTimestamp = Parser(Number, n => new Date(n).toISOString());
const date = DateFromString.parse("2024-01-15"); // Date object
const isoString = ISODateFromTimestamp.parse(1642204800000); // "2022-01-15T00:00:00.000Z"
// Complex object transformation
const UserInput = Object({
firstName: String,
lastName: String,
age: String, // Input as string
tags: String // Comma-separated string
});
const User = Parser(UserInput, input => ({
fullName: `${input.firstName} ${input.lastName}`,
age: parseInt(input.age, 10),
tags: input.tags.split(",").map(tag => tag.trim()),
createdAt: new Date()
}));
const userData = User.parse({
firstName: "Alice",
lastName: "Smith",
age: "25",
tags: "developer, typescript, nodejs"
});
// {
// fullName: "Alice Smith",
// age: 25,
// tags: ["developer", "typescript", "nodejs"],
// createdAt: Date
// }import { String, Number } from "runtypes";
// Chain multiple operations
const ProcessedUsername = String
.withConstraint(s => s.length >= 3 || "Username too short")
.withParser(s => s.toLowerCase())
.withParser(s => s.replace(/[^a-z0-9]/g, ""))
.withConstraint(s => s.length > 0 || "Username cannot be empty after processing")
.withBrand("Username");
const username = ProcessedUsername.parse("Alice_123!"); // "alice123" (branded)
// Numeric processing chain
const Currency = Number
.withConstraint(n => n >= 0 || "Amount cannot be negative")
.withParser(n => Math.round(n * 100) / 100) // Round to 2 decimal places
.withBrand("Currency");
const price = Currency.parse(19.999); // 20.00 (branded)import { Constraint, String, Object } from "runtypes";
// Validation that depends on other fields
const UserRegistration = Object({
username: String,
password: String,
confirmPassword: String
}).withConstraint((user): asserts user is any => {
if (user.password !== user.confirmPassword) {
throw "Passwords do not match";
}
if (user.password.includes(user.username)) {
throw "Password cannot contain username";
}
});
// Conditional validation
const ConditionalField = Object({
type: Union(Literal("premium"), Literal("basic")),
features: Array(String)
}).withConstraint((obj): asserts obj is any => {
if (obj.type === "premium" && obj.features.length === 0) {
throw "Premium type must have at least one feature";
}
});import { Constraint, String, Number } from "runtypes";
// Factory for creating range constraints
const createRange = (min: number, max: number) =>
Number.withConstraint(n =>
(n >= min && n <= max) || `Must be between ${min} and ${max}`
);
const Percentage = createRange(0, 100);
const Rating = createRange(1, 5);
// Factory for string patterns
const createPattern = (pattern: RegExp, message: string) =>
String.withConstraint(s => pattern.test(s) || message);
const PhoneNumber = createPattern(/^\+?[\d\s-()]+$/, "Invalid phone number format");
const ZipCode = createPattern(/^\d{5}(-\d{4})?$/, "Invalid ZIP code format");import { Constraint, String, ValidationError } from "runtypes";
// Custom error types
class ValidationFailure extends Error {
constructor(public field: string, public value: unknown, message: string) {
super(message);
this.name = "ValidationFailure";
}
}
const StrictEmail = Constraint(String, (s: string): asserts s is string => {
const parts = s.split("@");
if (parts.length !== 2) {
throw new ValidationFailure("email", s, "Email must contain exactly one @ symbol");
}
const [local, domain] = parts;
if (!local || local.length === 0) {
throw new ValidationFailure("email.local", local, "Email local part cannot be empty");
}
if (!domain || !domain.includes(".")) {
throw new ValidationFailure("email.domain", domain, "Email domain must contain a dot");
}
});
// Usage with error handling
try {
const email = StrictEmail.check("invalid-email");
} catch (error) {
if (error instanceof ValidationError) {
console.log("Validation failed:", error.failure.message);
} else if (error instanceof ValidationFailure) {
console.log(`Field ${error.field} failed:`, error.message);
}
}Install with Tessl CLI
npx tessl i tessl/npm-runtypes