Runtypes project

Runtime type combinator library with static type extraction and recursive record definitions; stresses recursive generic instantiation and union/intersection handling.

tsz unavailable 3437 lines 111 KB tsz error; tsc ok

Timing

tsz
failed
tsgo
n/a

README



Runtypes

License JSR NPM Version Coverage Status

Safely bring untyped data into the fold.



Runtypes allow you to take values about which you have no assurances and check that they conform to some type A. This is done by means of composable type validators of primitives, literals, arrays, tuples, objects, unions, intersections and more.

Example

Suppose you have objects which represent asteroids, planets, ships and crew members. In TypeScript, you might write their types like so:

type Vector = [number, number, number]

type Asteroid = {
	type: "asteroid"
	location: Vector
	mass: number
}

type Planet = {
	type: "planet"
	location: Vector
	mass: number
	population: number
	habitable: boolean
}

type Rank = "captain" | "first mate" | "officer" | "ensign"

type CrewMember = {
	name: string
	age: number
	rank: Rank
	home: Planet
}

type Ship = {
	type: "ship"
	location: Vector
	mass: number
	name: string
	crew: CrewMember[]
}

type SpaceObject = Asteroid | Planet | Ship

If the objects which are supposed to have these shapes are loaded from some external source, perhaps a JSON file, we need to validate that the objects conform to their specifications. We do so by building corresponding Runtypes in a very straightforward manner:

import { Boolean, Number, String, Literal, Array, Tuple, Object, Union } from "runtypes"

const Vector = Tuple(Number, Number, Number)

const Asteroid = Object({
	type: Literal("asteroid"),
	location: Vector,
	mass: Number,
})

const Planet = Object({
	type: Literal("planet"),
	location: Vector,
	mass: Number,
	population: Number,
	habitable: Boolean,
})

const Rank = Union(Literal("captain"), Literal("first mate"), Literal("officer"), Literal("ensign"))

const CrewMember = Object({
	name: String,
	age: Number,
	rank: Rank,
	home: Planet,
})

const Ship = Object({
	type: Literal("ship"),
	location: Vector,
	mass: Number,
	name: String,
	crew: Array(CrewMember),
})

const SpaceObject = Union(Asteroid, Planet, Ship)

(See the examples directory for an expanded version of this.)

Now if we are given a putative SpaceObject we can validate it like so:

// spaceObject: SpaceObject
const spaceObject = SpaceObject.check(value)

If the object doesn't conform to the type specification, check will throw an exception.

Error information

When it fails to validate, your runtype emits a ValidationError object that contains detailed information that describes what's the problem. Following properties are available in the object:

  • name: Always "ValidationError"
  • message: A string that summarizes the problem overall
  • failure: A Failure that describes the problem in a structured way

If you want to inspect why the validation failed, look into the failure object:

  • code: A Failcode that roughly categorizes the problem
  • message: A string that summarizes the problem overall
  • expected: The runtype that yielded this failure
  • received: The value that caused this failure
  • details: An object that describes which property was invalid precisely. Only available for complex runtypes (e.g. Object, Array, and the like; Union and Intersect also emit this enumerating a failure for each member)
  • detail: An object that describes the failure of the inner runtype. Only available for Brand and contextual failures (e.g. failures in Record keys, in boundaries of Contract/AsyncContract, etc.)
  • thrown: A thrown value, which is typically an error message, if any. Only available for runtypes that involve user-provided validation functions (e.g. Constraint, Parser, and InstanceOf) or constraint-like failures like about the length of Tuple

What shapes of failures there might actually be is documented on the JSDoc comment of each runtype.

If you want to inform your users about the validation error, it's strongly discouraged to rely on the format of the message property, as it may change across minor versions for readability thoughts. Instead of parsing message, you should use other properties to handle further tasks such as i18n.

Static type inference

The inferred type of Asteroid in the above example is a subtype of

Runtype<{
	type: "asteroid"
	location: [number, number, number]
	mass: number
}>

That is, it's a Runtype<Asteroid>, and you could annotate it as such. But we don't really have to define the Asteroid type at all now, because the inferred type is correct. Defining each of your types twice, once at the type level and then again at the value level, is a pain and not very DRY. Fortunately you can define a static Asteroid type which is an alias to the Runtype-derived type like so:

type Asteroid = Static<typeof Asteroid>

which achieves the same result as

type Asteroid = {
	type: "asteroid"
	location: [number, number, number]
	mass: number
}

Conforming to predefined static type

Instead of getting it to be inferred, you should be able to create a runtype that corresponds to a static type predefined somewhere. In such case you can statically ensure that your runtype conforms to the specification, by using .conform<T>():

type Specification = {
	foo: string
	bar?: string
}
const Correct = Object({
	foo: String,
	bar: String.optional(),
}).conform<Specification>()
// @ts-expect-error: should fail
const Wrong = Object({
	foo: String,
	bar: String,
}).conform<Specification>()

The error message on the wrong definition might be verbose like below, but you'll eventually find it contains where is the wrong piece if you scroll down the wall of text.

The 'this' context of type 'Object<{ foo: String; bar: String; }>' is not assignable to method's 'this' of type 'Conform<Specification>'.
	Type 'Object<{ foo: String; bar: String; }>' is not assignable to type 'Conformance<Specification>'.
		Types of property '[RuntypeConformance]' are incompatible.
			Type '(StaticTypeOfThis: { foo: string; bar: string; }) => { foo: string; bar: string; }' is not assignable to type '(StaticTypeOfThis: Specification) => Specification'.
				Types of parameters 'StaticTypeOfThis' and 'StaticTypeOfThis' are incompatible.
					Type 'Specification' is not assignable to type '{ foo: string; bar: string; }'.
						Property 'bar' is optional in type 'Specification' but required in type '{ foo: string; bar: string; }'.

Guard function

Runtypes provide a guard function as the guard method:

const disembark = (value: unknown) => {
	if (SpaceObject.guard(value)) {
		// value: SpaceObject
		if (value.type === "ship") {
			// value: Ship
			value.crew = []
		}
	}
}

Assertion function

Runtypes provide an assertion function as the assert method:

const disembark = (value: unknown) => {
	try {
		SpaceObject.assert(value)
		// value: SpaceObject
		if (value.type === "ship") {
			// value: Ship
			value.crew = []
		}
	} catch (error) {}
}

This might be uncomfortable that TypeScript requires you to manually write the type annotation for your runtype.

Constraint checking

Beyond mere type checking, we can add arbitrary runtime constraints to a Runtype:

const PositiveNumber = Number.withConstraint(n => n > 0)
PositiveNumber.check(-3) // Throws error: Failed constraint check

You can provide more descriptive error messages for failed constraints by returning a string instead of false:

const PositiveNumber = Number.withConstraint(n => n > 0 || `${n} is not positive`)
PositiveNumber.check(-3) // Throws error: -3 is not positive

Narrowing the static type

Constraint checking narrows down the original type to a subtype of it. This should be reflected on the static type. You can pass the desired type as the type argument:

const TheAnswer = Literal(42)
const WithConstraint = Number.withConstraint<42>(TheAnswer.guard)
type WithConstraint = Static<typeof WithConstraint> // 42

Alternatively, you can directly wire up the TypeScript's own facility to narrow down types: guard functions and assertion functions. There're corresponding methods on a runtype, so choose the most concise one:

const WithGuard = Number.withGuard(TheAnswer.guard)
type WithGuard = Static<typeof WithGuard> // 42
const WithAssertion = Number.withAssertion(TheAnswer.assert)
type WithAssertion = Static<typeof WithAssertion> // 42

If you want to provide custom error messages while narrowing static types, you can throw string or Error from a constraint, guard, or assertion function. Actually, returning a string from a function passed to withConstraint is supported by this exception handling internally.

Too often there might be cases you can't express desired types exactly in TypeScript, such as the type for positive numbers. In such cases you should at least express them as branded types.

const PositiveNumber = Number.withConstraint(n => n > 0).withBrand("PositiveNumber")

withBrand modifier is also useful when you want to give your runtype a custom name, which will be used in error messages.

Caveat

Note that the constraint function always receives a parsed value from the base runtype, even when being run in a non-parsing validation method such as check. This might result in an unexpected behavior, because not only Parser runtypes but also some basic runtypes inherently have different semantics depending on whether executed in a parsing or non-parsing context. For example, Object runtypes return the original value itself which may contain additional properties when used with check, whereas they return a new object containing only the specified properties when used with parse.

Template literals

The Template runtype validates that a value is a string that conforms to the template.

You can use the familiar syntax to create a Template runtype:

const T = Template`foo${Literal("bar")}baz`

But then the type inference won't work:

type T = Static<typeof T> // string

Because TS doesn't provide the exact string literal type information (["foo", "baz"] in this case) to the underlying function. See the issue microsoft/TypeScript#33304, especially this comment microsoft/TypeScript#33304 (comment) we hope to be implemented.

If you want the type inference rather than the tagged syntax, you have to manually write a function call:

const T = Template(["foo", "baz"] as const, Literal("bar"))
type T = Static<typeof T> // "foobarbaz"

As a convenient solution for this, it also supports another style of passing arguments:

const T = Template("foo", Literal("bar"), "baz")
type T = Static<typeof T> // "foobarbaz"

You can pass various things to the Template constructor, as long as they are assignable to string | number | bigint | boolean | null | undefined and the corresponding Runtypes:

// Equivalent runtypes
Template(Literal("42"))
Template(42)
Template(Template("42"))
Template(4, "2")
Template(Literal(4), "2")
Template(String.withConstraint(s => s === "42"))
Template(
	Intersect(
		Number.withConstraint(n => n === 42),
		String.withConstraint(s => s.length === 2),
		// `Number`s in `Template` accept alternative representations like `"0x2A"`,
		// thus we have to constraint the length of string, to accept only `"42"`
	),
)

Trivial items such as bare literals, Literals, and single-element Unions and Intersects are all coerced into strings at the creation time of the runtype. Additionally, Unions of such runtypes are converted into RegExp patterns like (?:foo|bar|...), so we can assume Union of Literals is a fully supported runtype in Template.

Caveats

A Template internally constructs a RegExp to parse strings. This can lead to a problem if it contains multiple non-literal runtypes:

const UpperCaseString = String.withConstraint(s => s === s.toUpperCase(), {
	name: "UpperCaseString",
})
const LowerCaseString = String.withConstraint(s => s === s.toLowerCase(), {
	name: "LowerCaseString",
})
Template(UpperCaseString, LowerCaseString) // DON'T DO THIS!

The only thing we can do for parsing such strings correctly is brute-forcing every single possible combination until it fulfills all the constraints, which must be hardly done. Actually Template treats String runtypes as the simplest RegExp pattern .* and the “greedy” strategy is always used, that is, the above runtype won't work expectedly because the entire pattern is just ^(.*)(.*)$ and the first .* always wins. You have to avoid using Constraint this way, and instead manually parse it using a single Constraint which covers the entire string.

Variadic tuples

You can spread a Tuple or an Array within arguments of Tuple.

const T = Tuple(Literal(0), ...Tuple(Literal(1), Literal(2)), Literal(3))
type T = Static<typeof T> // [0, 1, 2, 3]

const U = Tuple(Literal(0), ...Array(Literal(1)), Literal(2))
type U = Static<typeof U> // [0, ...1[], 2]

Component runtypes are expectedly inferred like this:

const T = Tuple(Literal(0), ...Tuple(Literal(1), Literal(2)), Literal(3))
const literal0: Literal<0> = T.components[0]
const literal1: Literal<1> = T.components[1]
const literal2: Literal<2> = T.components[2]
const literal3: Literal<3> = T.components[3]

Nested spreading is also supported:

const T = Tuple(Literal(0), ...Tuple(Literal(1), ...Array(Literal(2)), Literal(3)), Literal(4))
const leading0: Literal<0> = T.components.leading[0]
const leading1: Literal<1> = T.components.leading[1]
const rest: Array<Literal<2>> = T.components.rest
const trailing0: Literal<3> = T.components.trailing[0]
const trailing1: Literal<4> = T.components.trailing[1]

instanceof wrapper

If you have access to the class that you want to test values with the instanceof operator, then the InstanceOf runtype is exactly what you're looking for. Usage is straightforward:

class ObjectId { ... };
const ObjectIdChecker = InstanceOf(ObjectId);
ObjectIdChecker.check(value);

Branded types

Branded types is a way to emphasize the uniqueness of a type. This is useful until we have nominal types:

const Username = String.withBrand("Username")
const Password = String.withBrand("Password").withConstraint(
	str => str.length >= 8 || "Too short password",
)

const signIn = Contract({
	receives: Tuple(Username, Password),
	returns: Unknown,
}).enforce((username, password) => {
	/*...*/
})

const username = Username.check("someone@example.com")
const password = Password.check("12345678")

// Static type OK, runtime OK
signIn(username, password)

// Static type ERROR, runtime OK
signIn(password, username)

// Static type ERROR, runtime OK
signIn("someone@example.com", "12345678")

Optional properties

Object runtypes should be able to express optional properties. There's a modifier to do that: .optional().

Object({ x: Number.optional() })

You must be aware of the difference between Object({ x: Union(String, Undefined) }) and Object({ x: String.optional() }); the former means “x must be present, and must be string or undefined”, while the latter means “x can be present or absent, but must be string if present”.

It's strongly discouraged to disable "exactOptionalPropertyTypes" in the tsconfig; if you do so, the correspondence between runtypes and the inferred static types will be lost. We can't respect tsconfig at runtime, so runtypes always conform to the behavior "exactOptionalPropertyTypes": true, in favor of the expressiveness.

Exact object validation

Object has a modifier to perform exact object validation: .exact().

const O = Object({ x: Number }).exact()
O.guard({ x: 42 }) // true
O.guard({ x: 42, y: 24 }) // false

Note that TypeScript doesn't have exact types at the moment, so it's recommended to wrap your exact Object runtype within a Brand to at least prevent the unexpected behavior of the inferred static type:

const x0 = { x: 42 }
const x1 = { x: 42, y: 24 }

const O = Object({ x: Number }).exact()
type O = Static<typeof O>
const o0: O = x0
const o1: O = x1 // You would not want this to be possible.
globalThis.Object.hasOwn(o1, "y") === true

const P = O.withBrand("P")
type P = Static<typeof P>
const p0: P = P.check(x0) // Branded types require explicit assertion.
const p1: P = P.check(x1) // So this won't accidentally pass at runtime.

You should beware that Object validation only respects enumerable own keys; thus if you want to completely eliminate extra properties that may be non-enumerable or inherited, use parse method.

Parsing after validation

Every runtype has the withParser and parse methods that offer the functionality to transform validated values automatically.

const O = Object({ x: String.withParser(parseInt).default(42) })
type OStatic = Static<typeof O> // { x: string }
type OParsed = Parsed<typeof O> // { x: number }
O.parse({ x: "42" }).x === 42

The .default(...) modifier works the same

...