CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-io-ts

TypeScript runtime type system for IO decoding/encoding

72

1.14x
Overview
Eval results
Files

refinement.mddocs/

Refinement & Branding

Advanced type construction for adding runtime constraints and creating branded types.

Capabilities

Refinement Function

Add runtime constraints to existing codecs with custom predicate functions.

/**
 * Add a refinement predicate to an existing codec
 * @param codec - The base codec to refine
 * @param predicate - Predicate function for additional validation
 * @param name - Optional name for the refined codec
 */
function refinement<C extends Any, B extends TypeOf<C>>(
  codec: C,
  refinement: Refinement<TypeOf<C>, B>,
  name?: string
): RefinementC<C, B>;

function refinement<C extends Any>(
  codec: C,
  predicate: Predicate<TypeOf<C>>,
  name?: string
): RefinementC<C>;

interface RefinementC<C extends Any, B = TypeOf<C>> extends RefinementType<C, B, OutputOf<C>, InputOf<C>> {}

/** Predicate function type */
type Predicate<A> = (a: A) => boolean;

/** Refinement function type */
type Refinement<A, B extends A> = (a: A) => a is B;

Usage Examples:

import * as t from "io-ts";

// Create a positive number codec
const PositiveNumber = t.refinement(
  t.number,
  (n): n is number => n > 0,
  'PositiveNumber'
);

const result1 = PositiveNumber.decode(42);
// result: Right(42)

const result2 = PositiveNumber.decode(-5);
// result: Left([validation error])

// Create an email codec using string refinement
const Email = t.refinement(
  t.string,
  (s): s is string => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s),
  'Email'
);

const email = Email.decode("user@example.com");
// result: Right("user@example.com")

// Create non-empty array codec
const NonEmptyArray = <A>(codec: t.Type<A>) =>
  t.refinement(
    t.array(codec),
    (arr): arr is [A, ...A[]] => arr.length > 0,
    `NonEmptyArray<${codec.name}>`
  );

const NonEmptyStrings = NonEmptyArray(t.string);
const result3 = NonEmptyStrings.decode(["hello"]);
// result: Right(["hello"])

const result4 = NonEmptyStrings.decode([]);
// result: Left([validation error])

Brand Function

Create branded types that are distinct at the type level while sharing runtime representation.

/**
 * Create a branded codec that adds a compile-time brand to values
 * @param codec - The base codec to brand  
 * @param predicate - Refinement predicate for the brand
 * @param name - Brand name (becomes part of the type)
 */
function brand<C extends Any, N extends string, B extends { readonly [K in N]: symbol }>(
  codec: C,
  predicate: Refinement<TypeOf<C>, Branded<TypeOf<C>, B>>,
  name: N
): BrandC<C, B>;

interface BrandC<C extends Any, B> extends RefinementType<C, Branded<TypeOf<C>, B>, OutputOf<C>, InputOf<C>> {}

/** Brand interface for creating branded types */
interface Brand<B> {
  readonly [_brand]: B;
}

/** Branded type combining base type with brand */
type Branded<A, B> = A & Brand<B>;

Usage Examples:

import * as t from "io-ts";

// Create a UserId brand
interface UserIdBrand {
  readonly UserId: unique symbol;
}

const UserId = t.brand(
  t.number,
  (n): n is t.Branded<number, UserIdBrand> => Number.isInteger(n) && n > 0,
  'UserId'
);

type UserId = t.TypeOf<typeof UserId>;
// UserId = number & Brand<UserIdBrand>

const userId = UserId.decode(123);
// result: Right(123) with UserId brand

// Create an Email brand
interface EmailBrand {
  readonly Email: unique symbol;
}

const EmailBranded = t.brand(
  t.string,
  (s): s is t.Branded<string, EmailBrand> => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s),
  'Email'
);

type Email = t.TypeOf<typeof EmailBranded>;
// Email = string & Brand<EmailBrand>

// Branded types prevent accidental mixing
function sendEmail(to: Email, from: UserId) {
  // Implementation
}

const email = EmailBranded.decode("user@example.com");
const id = UserId.decode(456);

if (email._tag === "Right" && id._tag === "Right") {
  sendEmail(email.right, id.right); // Type safe
  // sendEmail(id.right, email.right); // Type error!
}

Built-in Integer Brand

Pre-defined integer codec using the branding system.

/** Integer brand interface */
interface IntBrand {
  readonly Int: unique symbol;
}

/** Branded integer type */
type Int = Branded<number, IntBrand>;

/** Integer codec that validates integer numbers */
const Int: BrandC<NumberC, IntBrand>;

/** Pre-defined Integer codec (deprecated, use Int) */
const Integer: RefinementC<NumberC>;

Usage Example:

import * as t from "io-ts";

const result1 = t.Int.decode(42);
// result: Right(42) with Int brand

const result2 = t.Int.decode(42.5);
// result: Left([validation error - not an integer])

const result3 = t.Int.decode("42");
// result: Left([validation error - not a number])

// Type-level distinction
function processInt(value: t.Int) {
  // value is guaranteed to be an integer
  return value * 2;
}

if (result1._tag === "Right") {
  const doubled = processInt(result1.right); // Type safe
}

Advanced Usage Patterns

Multiple Refinements

import * as t from "io-ts";

// Chain multiple refinements
const PositiveEvenNumber = t.refinement(
  t.refinement(
    t.number,
    (n): n is number => n > 0,
    'PositiveNumber'
  ),
  (n): n is number => n % 2 === 0,
  'EvenNumber'
);

const result = PositiveEvenNumber.decode(10);
// result: Right(10) - positive and even

const invalid1 = PositiveEvenNumber.decode(-2);
// result: Left([validation error - not positive])

const invalid2 = PositiveEvenNumber.decode(3);
// result: Left([validation error - not even])

Custom Branded Types

import * as t from "io-ts";

// Create a URL brand
interface UrlBrand {
  readonly Url: unique symbol;
}

const Url = t.brand(
  t.string,
  (s): s is t.Branded<string, UrlBrand> => {
    try {
      new URL(s);
      return true;
    } catch {
      return false;
    }
  },
  'Url'
);

type Url = t.TypeOf<typeof Url>;

// Create a non-empty string brand
interface NonEmptyStringBrand {
  readonly NonEmptyString: unique symbol;
}

const NonEmptyString = t.brand(
  t.string,
  (s): s is t.Branded<string, NonEmptyStringBrand> => s.length > 0,
  'NonEmptyString'
);

type NonEmptyString = t.TypeOf<typeof NonEmptyString>;

// Use branded types in complex structures
const UserProfile = t.type({
  name: NonEmptyString,
  website: Url,
  id: t.Int
});

type UserProfile = t.TypeOf<typeof UserProfile>;

Range-based Refinements

import * as t from "io-ts";

// Create range validation
const range = (min: number, max: number) =>
  t.refinement(
    t.number,
    (n): n is number => n >= min && n <= max,
    `Range(${min}, ${max})`
  );

const Age = range(0, 150);
const Percentage = range(0, 100);
const Temperature = range(-273.15, Infinity);

const User = t.type({
  name: t.string,
  age: Age,
  score: Percentage
});

const result = User.decode({
  name: "Alice",
  age: 25,
  score: 95
});
// result: Right({ name: "Alice", age: 25, score: 95 })

const invalid = User.decode({
  name: "Bob", 
  age: 200, // Too old
  score: 110 // Over 100%
});
// result: Left([validation errors for age and score])

String Pattern Refinements

import * as t from "io-ts";

// Create pattern-based string codecs
const pattern = (regex: RegExp, name: string) =>
  t.refinement(
    t.string,
    (s): s is string => regex.test(s),
    name
  );

const PhoneNumber = pattern(/^\+?[\d\s()-]+$/, 'PhoneNumber');
const PostalCode = pattern(/^\d{5}(-\d{4})?$/, 'PostalCode');
const HexColor = pattern(/^#[0-9A-Fa-f]{6}$/, 'HexColor');

const ContactInfo = t.type({
  phone: PhoneNumber,
  zip: PostalCode,
  favoriteColor: HexColor
});

const result = ContactInfo.decode({
  phone: "+1 (555) 123-4567",
  zip: "12345",
  favoriteColor: "#FF0000"
});
// result: Right(contact info)

Combining Brands and Refinements

import * as t from "io-ts";

// Create a sophisticated ID system
interface ProductIdBrand {
  readonly ProductId: unique symbol;
}

interface CategoryIdBrand {
  readonly CategoryId: unique symbol;
}

const ProductId = t.brand(
  t.refinement(
    t.string,
    (s): s is string => s.startsWith('PROD-') && s.length === 10,
    'ProductIdFormat'
  ),
  (s): s is t.Branded<string, ProductIdBrand> => true,
  'ProductId'
);

const CategoryId = t.brand(
  t.refinement(
    t.string,
    (s): s is string => s.startsWith('CAT-') && s.length === 9,
    'CategoryIdFormat'
  ),
  (s): s is t.Branded<string, CategoryIdBrand> => true,
  'CategoryId'
);

type ProductId = t.TypeOf<typeof ProductId>;
type CategoryId = t.TypeOf<typeof CategoryId>;

// These types are now completely distinct
function assignCategory(productId: ProductId, categoryId: CategoryId) {
  // Implementation
}

const prodId = ProductId.decode("PROD-12345");
const catId = CategoryId.decode("CAT-54321");

if (prodId._tag === "Right" && catId._tag === "Right") {
  assignCategory(prodId.right, catId.right); // Type safe
  // assignCategory(catId.right, prodId.right); // Type error!
}

Install with Tessl CLI

npx tessl i tessl/npm-io-ts

docs

codec.md

combinators.md

core-types.md

decoder.md

encoder.md

index.md

infrastructure.md

primitives.md

refinement.md

reporters.md

schema.md

task-decoder.md

validation.md

tile.json