CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-runtypes

Runtime validation for static types

Pending
Overview
Eval results
Files

constraints.mddocs/

Constraints and Transformations

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.

Capabilities

Constraint

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"

Built-in Constraint Helpers

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");

Brand

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");

Parser

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
// }

Chaining Constraints and Transformations

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)

Advanced Constraint Patterns

Custom Validation with Context

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";
  }
});

Dynamic Constraints

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");

Error Handling in Constraints

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

docs

composite.md

constraints.md

contracts.md

index.md

literals.md

primitives.md

results.md

templates.md

union-intersect.md

utilities.md

validation.md

tile.json