Guide

Type Formats

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

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/core/formats';
import {createValidateFn} from '@ts-runtypes/core';

// 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 = createValidateFn<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/core/formats';
import {createValidateFn, type InferType} from '@ts-runtypes/core';
import * as RT from '@ts-runtypes/core/builders';

// Builder 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(),
});

// InferType<typeof runType> hands the TypeScript type back.
type Account = InferType<typeof account>;

const isAccount = createValidateFn(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 five 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, emailAddress, uuidv4, uuidv7, url, uri, ipv4, ipv6, domain, hostname, idnHostname, regexString, alpha, numeric, lowercase
Numberint8, int16, int32, uint8, uint16, uint32, integer, float, positive, negative, currency
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
Array / objectnot named presets but options on a collection: minItems, maxItems, uniqueItems, contains, minProperties, maxProperties, patternProperties, propertyNames (see below)

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/core/formats'). With builders, the matching calls 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.

Some formats exist mainly so a JSON Schema keyword has something exact to mean: TF.StringDuration for a length of time (P4DT12H30M5S), TF.JsonPointer and TF.RelativeJsonPointer for the path syntax, TF.UriReference and TF.UriTemplate for the URI variants, and TF.Iri / TF.IriReference for the same shapes with non-ASCII characters allowed. They work the same way anywhere else you want them.

TF.Hostname is a host name in the ordinary sense, so a single label like localhost passes where TF.Domain would want a dotted name. TF.IdnHostname additionally accepts a name written in its own script, such as 실례.테스트. Both check a punycode label (the xn-- form) by decoding it, so a label that merely looks like one does not slip through.

TF.EmailAddress and TF.IdnEmail are the full addressing grammar, where a quoted local part may hold spaces or even an at sign, and the domain may be an address literal in brackets. TF.Email stays the everyday shape, which is usually the one a form field wants. All three expect a dotted domain, so an address like joe@tld is rejected even though the strictest reading of the RFC would allow it. TF.RegexString is a string that has to compile as a regular expression, which is a different question from the pattern option: there the value must match a regex, here the value is one.

The IP formats describe an address, so they accept an address and nothing else: TF.IPv4 passes 192.168.0.1 and 127.0.0.1, and turns down the word localhost. When a setting genuinely takes either, opt in with TF.IPv4<{allowLocalHost: true}> (builder TF.ipv4({allowLocalHost: true})). The option covers that one hostname spelling. Loopback addresses like ::1 are ordinary addresses and never need it.

TF.Currency (builder TF.currency()) is a number marked as a monetary amount, a preset that sets the isCurrency param on the plain number format. It validates and serializes exactly like TF.Number; the mark exists for presentation, so friendly error messages can render a violated limit with the right currency symbol and decimals for the reader's language.

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/core/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/core/formats';
import type * as TFT from '@ts-runtypes/core/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.)

With builders, the same calls live under TF.date(...) and the TFT.* temporal namespace:

import * as TF from '@ts-runtypes/core/formats';
import * as TFT from '@ts-runtypes/core/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/core/formats';
import {createValidateFn} from '@ts-runtypes/core';

// 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 = createValidateFn<BirthDate>();
const startsSoon = createValidateFn<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.

Constraints on arrays and objects

The string and number formats refine a single value. Arrays and objects need something different: their shape is already a TypeScript type, and what is left to say is about the collection itself. How many entries. Whether they repeat. Whether at least one of them matches something. What the keys look like.

All of that is one options bag, and there are only two wrappers to know.

Type-firstType Builder
Arrays and tuplesTF.FormattedArray<T[], {...}>RT.array(item, {...})
Objects and recordsTF.FormattedObject<Shape, {...}>RT.object(config, {...}), RT.record(inner, {...})

Every option lives in that bag, so there is one thing to learn rather than a wrapper per constraint.

OptionOnWhat it asks
minItems / maxItemsarrayshow many entries there may be
uniqueItemsarraysno two entries are equal, compared by value
containsarraysat least one entry matches a second type
minContains / maxContainsarrayshow many entries must match contains
minProperties / maxPropertiesobjectshow many keys the value may carry
patternPropertiesobjectskeys matching a pattern carry a given type
propertyNamesobjectsevery key satisfies a string format
// Everything you can say about an array beyond its element type rides one
// options bag, whichever way you write it.
type Tags = TF.FormattedArray<string[], {minItems: 1; maxItems: 5; uniqueItems: true}>;

const isTags = createValidateFn<Tags>();
const isTagsBuilt = createValidateFn(RT.array(TF.string(), {minItems: 1, maxItems: 5, uniqueItems: true}));

isTags(['ada', 'grace']); // true
isTags([]); // false, at least one entry
isTagsBuilt(['ada', 'ada']); // false, the entries repeat

// The two spellings are one generated function, not two that agree.
getRunTypeId<Tags>() === getRunTypeId(RT.array(TF.string(), {minItems: 1, maxItems: 5, uniqueItems: true})); // true

Some of these say something no TypeScript type can express. uniqueItems is the clearest case: there is no type for "an array whose entries differ". The recovered type stays the closest honest shape (string[]), and the generated function carries the real check, so nothing is quietly dropped.

contains takes a second type rather than a value, so it reads a little differently in each mode: the type-first form takes the element type, the builder takes a runtype.

// `contains` asks that at least one entry match a second type. The options bag
// takes the element TYPE when you write the type, and a runtype when you build.
type WithAdminId = TF.FormattedArray<string[], {contains: TF.UUID; minContains: 2}>;

const hasTwoIds = createValidateFn<WithAdminId>();
const hasTwoIdsBuilt = createValidateFn(RT.array(TF.string(), {contains: TF.uuid(), minContains: 2}));

hasTwoIds(['plain', '9f1b8c2e-3d4a-4b5c-8d6e-1f2a3b4c5d6e', '018f1b8c-2e3d-7b5c-8d6e-1f2a3b4c5d6e']); // true
hasTwoIdsBuilt(['plain', '9f1b8c2e-3d4a-4b5c-8d6e-1f2a3b4c5d6e']); // false, only one matches

Objects work the same way. The shape stays the type you already write, and everything about the keys is an option.

// Objects work the same way: the shape is the type, and everything else about
// the keys is an option.
interface Settings {
  theme: string;
  locale?: string;
}

type BoundedSettings = TF.FormattedObject<Settings, {minProperties: 1; maxProperties: 2}>;

const isSettings = createValidateFn<BoundedSettings>();
const isSettingsBuilt = createValidateFn(
  RT.object({theme: TF.string(), locale: RT.optional(TF.string())}, {minProperties: 1, maxProperties: 2})
);

isSettings({theme: 'dark'}); // true
isSettingsBuilt({theme: 'dark', locale: 'en'}); // true
// Key patterns and key formats are options too. `patternProperties` maps a
// pattern to the type its matching keys carry; `propertyNames` constrains every
// key at once.
type Columns = TF.FormattedObject<Record<string, unknown>, {patternProperties: {'^col_': number}}>;
type LowercaseKeys = TF.FormattedObject<Record<string, string>, {propertyNames: TF.Alpha}>;

const isColumns = createValidateFn<Columns>();
const isLowercaseKeys = createValidateFn<LowercaseKeys>();

isColumns({col_width: 40}); // true
isColumns({col_width: 'wide'}); // false, a col_ key must hold a number
isLowercaseKeys({theme: 'dark'}); // true
isLowercaseKeys({'theme-2': 'dark'}); // false, the key is not alphabetic
These are the same options a JSON Schema uses, under the same names. When you generate a JSON Schema document from a type, uniqueItems or patternProperties ride the standard keywords, so the document enforces exactly what the type does.

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/core/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.

With builders, 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/core/formats';
import {createValidateFn} from '@ts-runtypes/core';

// 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 = createValidateFn<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, isCurrency
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 does not need mockSamples: declare none and the build generates valid values from the regex, fresh on every build (a literal seed on createMockDataFn pins them, see Mocking). Declare your own samples for curated values, or when the build reports it cannot generate for a construct like lookbehind. Declared samples always win, must fit any length bounds the type declares, and each one is checked at build time with the same regular expression engine your app runs on, so a bad sample or a pattern with a syntax error fails the build instead of surprising you at runtime. A bare regex value like /x/ is not accepted (spell it as {source: '...'}). Reusing a pattern across types? Register it once with registerFormatPattern and reference it by typeof; a registered pattern can also carry a message, which appears as the reported value when validation fails.

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