Guide

Type Formats

Bake constraints like email, UUID, int32 or positive straight into your types or schemas.

Two ways, same result

Import the format type from the TF namespace and annotate, or reach for the matching TF.* builder if you like that feel. Either way gives you the same constraints.

import type * as TF from 'ts-runtypes/formats';
import {createValidate} from 'ts-runtypes';

// Type-first formats: import a Format* alias and annotate. The constraint
// lives in the type — the build reads it and validates accordingly.
type Account = {
  id: TF.UUIDv4;
  email: TF.Email;
  age: TF.Int32;
  credits: TF.Positive;
};

const isAccount = createValidate<Account>();

isAccount({
  id: '109156be-c4fb-41ea-b1b4-efe1671c5836',
  email: 'ada@example.com',
  age: 36,
  credits: 100,
}); // true

isAccount({id: 'not-a-uuid', email: 'nope', age: 1.5, credits: -5}); // false

export {isAccount};
export type {Account};
import * as TF from 'ts-runtypes/formats';
import {createValidate, type Static} from 'ts-runtypes';
import * as RT from 'ts-runtypes/schema';

// Schema-first formats: the same constraints as builders. TF.email(),
// TF.uuidv4(), TF.int32(), TF.positive() — pick the style you like.
const account = RT.object({
  id: TF.uuidv4(),
  email: TF.email(),
  age: TF.int32(),
  credits: TF.positive(),
});

// Static<typeof schema> hands the TypeScript type back.
type Account = Static<typeof account>;

const isAccount = createValidate(account);

export {account, isAccount};
export type {Account};

Both compile to the exact same validator. Mix them in the same file if you want.

What's in the box

Formats come in four families. Here are the named ones (there are more, but these are the ones you'll reach for):

FamilyA few of the named formats
Stringemail, uuidv4, uuidv7, url, ipv4, ipv6, domain, alpha, numeric, lowercase
Numberint8, int16, int32, uint8, uint16, uint32, integer, float, positive, negative
BigIntbigInt64, bigUInt64, bigPositive, bigNegative, bigPositiveInt, bigNegativeInt
Date / timethree representations, all sharing the same bounds (see below): string-encoded (stringDate, stringTime, stringDateTime), native date, and the TC39 temporal.* types

Type-first, it's the capitalized name on the TF namespace, like TF.Email, TF.Int32, TF.BigInt64, TF.StringDate (from import * as TF from 'ts-runtypes/formats'). Schema-first, the matching builders are TF.email(), TF.int32(), TF.bigInt64(), TF.stringDate().

The fixed-width number/bigint formats (int32, bigUInt64, …) aren't just validation. They also tell the binary codec how many bytes to pack. A TF.UInt8 field rides the wire in a single byte.

Dates, times and Temporal

A date-ish value shows up in three shapes, and there's a format family for each. They all take the same min / max / gt / lt bounds, so a constraint means the same thing whichever representation you pick:

  • String-encoded: TF.StringDate, TF.StringTime, TF.StringDateTime check an ISO string in a fixed layout ('2020-01-01', '08:30').
  • Native Date: TF.Date checks a real JS Date; TF.DateFuture / TF.DatePast are ready-made (a Date that's >= now / <= now).
  • TC39 Temporal: TFT.PlainDate, TFT.ZonedDateTime, TFT.Instant, TFT.PlainTime, TFT.PlainDateTime, TFT.PlainYearMonth check an actual Temporal.* instance. Opt in from the dedicated ts-runtypes/formats/temporal subpath (imported as TFT), so consumers who don't use Temporal never pull in its lib.
import type * as TF from 'ts-runtypes/formats';
import type * as TFT from 'ts-runtypes/formats/temporal';

// The same "2020 or later" bound, two representations:
type DateString = TF.StringDate<{min: '2020-01-01'}>; // an ISO string
type PlainDate = TFT.PlainDate<{min: '2020-01-01'}>;  // a Temporal.PlainDate

The bound literal is written in the type's own ISO form. A PlainDate takes '2020-01-01', an Instant takes '2020-01-01T00:00:00Z'. (Temporal.PlainMonthDay and Temporal.Duration have no min/max ordering, so they're validated by identity only, with no bounds.)

Schema-first, the same builders live under TF.date(...) and the TFT.* temporal namespace:

import * as TF from 'ts-runtypes/formats';
import * as TFT from 'ts-runtypes/formats/temporal';

TF.date({max: 'now'});               // a Date in the past
TFT.plainDate({min: '2020-01-01'});  // a Temporal.PlainDate, 2020 on
TFT.instant({min: 'now-PT1H'});      // an Instant within the last hour
Temporal values are validated by identity (instanceof), not by structural shape. They also survive DataOnly whole, so DataOnly<Temporal.PlainDate> stays a Temporal.PlainDate.

Relative bounds with now

A bound doesn't have to be an absolute date. Write now, or now plus or minus an ISO-8601 duration (now+P… / now-P…), and the build resolves it against the current time every time it validates:

import type * as TF from 'ts-runtypes/formats';
import {createValidate} from 'ts-runtypes';

// A bound can be RELATIVE: `now`, or `now` ± an ISO-8601 duration. The build
// resolves it against the current time each time it validates a value.

// A birth date in the past, no more than 120 years ago.
type BirthDate = TF.StringDate<{min: 'now-P120Y'; max: 'now'}>;

// A meeting that starts within the next 30 days.
type StartsSoon = TF.StringDateTime<{min: 'now'; max: 'now+P30D'}>;

const isBirthDate = createValidate<BirthDate>();
const startsSoon = createValidate<StartsSoon>();

isBirthDate('1990-05-20'); // true
isBirthDate('1850-01-01'); // false — more than 120 years ago

export {isBirthDate, startsSoon};

The P… tail is a full ISO-8601 duration, such as P1Y2M10D (1 year, 2 months, 10 days), P1W (1 week), or PT12H30M (12 hours, 30 minutes). The build checks the units fit the field: a date-only format takes only date units, a time-only format only time units.

FamilyRelative units
Date-only (TF.stringDate, TFT.plainDate, TFT.plainYearMonth)date: Y M W D
Time-only (TF.stringTime, TFT.plainTime, TFT.instant)time: H M S
Date + time (TF.stringDateTime, native TF.date, TFT.plainDateTime, TFT.zonedDateTime)both

min / max are inclusive; gt / lt are the exclusive twins. Give each edge as one or the other, never both.

Branded (nominal) types

By default a format is still structurally a string or number. That's handy, but it won't stop you from passing a raw string where a UserId belongs. Add a brand name (the second type argument) and the format becomes nominal: nothing else is assignable to it without an explicit as cast.

import type * as TF from 'ts-runtypes/formats';

// Add a brand name (the 2nd type arg) and the format becomes a NOMINAL type.
// A plain string is no longer assignable — you must opt in with `as`.
type UserId = TF.String<{minLength: 1}, 'UserId'>;
type Cents = TF.Number<{min: 0; integer: true}, 'Cents'>;

// A bare string won't fit — that's the point. Cast at the boundary where
// you've actually checked the value.
const id = 'usr_abc123' as UserId;
const price = 4999 as Cents;

// Now UserId and Cents don't mix with each other or with raw string/number.
function chargeUser(_user: UserId, _amount: Cents): void {}
chargeUser(id, price); // ok

export {id, price, chargeUser};
export type {UserId, Cents};
The as cast is a feature, not a chore. It marks the exact line where you've decided a raw value is now a UserId, usually right after you've validated it. Everywhere else, the type system keeps your ids, cents and timestamps from getting mixed up.

Schema-first, brand with the brand() tag: TF.string({minLength: 1}, TF.brand('UserId')).

Custom formats

No named format fits? TF.String, TF.Number and TF.BigInt are the escape hatches. Pass your own params.

import type * as TF from 'ts-runtypes/formats';
import {createValidate} from 'ts-runtypes';

// TF.String / TF.Number / TF.BigInt are the escape hatches: pass
// your own params when no named format fits.
type Username = TF.String<{minLength: 3; maxLength: 20; pattern: {source: '^[a-z0-9_]+$'; mockSamples: ['ada_99', 'grace']}}>;
type Percentage = TF.Number<{min: 0; max: 100}>;
type BigPositive = TF.BigInt<{min: 0n}>;

type Profile = {
  handle: Username;
  completion: Percentage;
  followers: BigPositive;
};

const isProfile = createValidate<Profile>();

isProfile({handle: 'ada_99', completion: 80, followers: 1200n}); // true
isProfile({handle: 'no', completion: 150, followers: -1n}); // false

export {isProfile};
export type {Profile};

The common params:

FormatParams
TF.StringminLength, maxLength, length, pattern, allowedChars, allowedValues, …
TF.Numbermin, max, gt, lt, integer, float, multipleOf
TF.BigIntmin, max, gt, lt, multipleOf (all bigint literals, e.g. 0n)

min/max are inclusive; gt/lt are their exclusive twins. A bound is one or the other, never both. The build rejects {min: 0, gt: 0}.

A pattern always needs mockSamples, the canonical valid values the mock generator can draw from (a bare regex with no samples is rejected). Reusing a pattern across types? Register it once with registerFormatPattern and reference it by typeof.

Smaller on the wire

A format constraint is not only a validation rule. The binary codec uses it to size the payload: a fixed width like int8, or a min and max bound, lets the encoder pack the value into the narrowest field that fits, instead of the 8 bytes an unconstrained number or bigint needs. The serialization formats benchmark measures the saving per type.

Copyright © 2026