runtypes: a stable Static alias, an unstable error message, and a Deno test run with net access
Runtime validation for static types
At a glance
- What is it?
- Runtime validation for static types, assembled from composable Runtype validators in TypeScript. The detail worth knowing sits in the seams: a Failure object whose fields show up only for certain runtypes, an error message documented as free to change between minor versions, and a build and test toolchain that runs on Deno while the dev dependencies are pinned for pnpm.
- Who is it for?
- runtypes suits codebases where untyped values cross a boundary and the receiving side wants one definition of the shape instead of two.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 74 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 25, 2026, and from our analysis. They are not legal advice.
Star history
- runtypes/runtypes2,703 ★ · +3
| Repository | 2026-10-04 | 2026-10-25 |
|---|---|---|
| runtypes/runtypes | 2.7k | 2.7k |
3 Stars in the last 21 days, measured from the daily GitHub snapshot. Compare with other projects
Editorial analysis
Static<typeof Asteroid> exists so a shape is written once
The walkthrough defines every shape twice on purpose, first as a TypeScript type and then as a Runtype value, so the reader can compare the two. The library's answer to that duplication is that you do not need the first one. What Object({ type: Literal("asteroid"), location: Vector, mass: Number }) infers is a subtype of
Runtype<{
type: "asteroid"
location: [number, number, number]
mass: number
}>so the inferred type is already correct, and an alias of the form type Asteroid = Static<typeof Asteroid> reaches the same place as writing the object type by hand. The pitch is stated plainly: defining each type once at the type level and again at the value level is a pain and not very DRY. One caveat about the walkthrough itself: Asteroid and Planet are shown through to their last field, while the Ship runtype in the visible example stops right after its type discriminator, and the expanded version is pointed at the examples directory, which holds basic/ and brand/ cases.
conform<T>() turns a mismatched hand-written interface into a compile error
Inference covers runtypes written from scratch, but the case where a TypeScript type already exists somewhere else is handled by .conform<T>(). The example pairs a Specification of foo: string and bar?: string with two candidate definitions, one of which is expected to fail at compile time:
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 difference is String.optional() against a required String. What a reader gets instead of a one line error is a wall of text that begins with the 'this' context of the object not being assignable to the method's 'this' of type Conform<Specification>, and the offending property is only found further down, as the text narrows from property incompatibilities to parameter types.
The message field is documented as free to change, the rest is not
When validation fails, the thrown object is a ValidationError with three properties: name, which is always the string "ValidationError", message, a string that summarizes the problem overall, and failure, the structured record. The advice attached to message is unusually blunt: relying on its format is strongly discouraged because it may change across minor versions for readability reasons, and other properties exist precisely so that handling, including i18n, does not depend on parsing text. Everything a caller would want for a real decision lives in failure instead, whose own properties are code (a Failcode that roughly categorizes the problem), message (again an overall summary), expected (the runtype that produced the failure) and received (the value that caused it). The shapes that a given failure can take are documented on the JSDoc comment of each runtype, not in one central table.
Three of the failure fields exist only for certain runtypes
Reading failure as if it were one fixed shape is where callers get caught. details, which describes precisely which property was invalid, is available only for complex runtypes, Object and Array and the like, and Union and Intersect emit it while enumerating one failure per member. detail, which describes the failure of the inner runtype, is reserved for Brand and for contextual failures such as those in Record keys and at the boundaries of Contract and AsyncContract. thrown, carrying a value that is typically an error message, exists only where user-provided validation functions are involved, meaning Constraint, Parser and InstanceOf, plus constraint-like failures such as the length of a Tuple. So the three deepest fields are mutually exclusive by runtype rather than cumulative, and the fields a caller can rely on across every failure are name, code, expected and received.
guard and assert differ by one try block, and both narrow the value
Two methods sit on every runtype for narrowing an unknown value. The guard form is a predicate: if SpaceObject.guard(value) holds, the value is a SpaceObject inside the branch, and a further test on value.type narrows it to Ship, at which point fields like crew become assignable. The assert form is the same narrowing expressed as an assertion, SpaceObject.assert(value), and the example wraps it in try and catch because TypeScript wants the narrowing to flow through an exception path rather than a boolean. Both examples then do the same work, replacing a crew array with an empty one. Neither method produces a value: they exist to let the compiler stop treating the input as unknown, which is why the surrounding code, not the library, is what handles a failure.
The test recipe runs on Deno with net and read permissions
The repository is developed against Deno rather than only against Node, and the test recipe says so directly:
test *ARGS='src/**/*.test.ts src/*.test.ts':
deno test --coverage=coverage --allow-net --allow-read --unstable-unsafe-proto {{ARGS}}
deno coverage coverageThe permissions are the interesting part for a library whose whole job is checking values: --allow-net and --allow-read are granted for a test suite over pure validators, and --unstable-unsafe-proto is on top. The ci recipe runs the same command and then writes LCOV through a shell redirection, so coverage output has two shapes, a default report and a file for a service. Type checking has its own recipe, deno check, whose glob covers src, its tests and the examples directory, meaning the examples are type-checked as part of the project rather than left to rot. The build is deno run -A build.ts, which hands the build script every permission Deno can grant.
The root package.json is private and carries no package metadata
There are two package manifests and the one at the root is not the shipped one: it sets "private": true and lists only devDependencies and a packageManager entry for pnpm at 8.12.0. Those dependencies are pinned to exact versions rather than ranges, typescript 5.6.2, eslint 8.56.0, prettier 3.3.3, @typescript-eslint at 8.19.0, lint-staged 15.2.9 and eclint 2.8.1 for editorconfig lines, plus eslint plugins that pin house style directly, among them sort-exports, prefer-arrow-functions, consistent-default-export-name and unused-imports. The publishable package is assembled elsewhere: build.ts writes it, package.build.json sits beside the root manifest, and the pack recipe depends on build and then runs pnpm pack inside the build directory. Deno has its own configuration files in deno.json and import-map.json, and a .tool-versions file pins tool versions. Hooks live in .githooks/ and are activated by pointing core.hooksPath at that directory, so formatting and lint rules run from committed scripts rather than from each developer's global setup.
Two patch tags on one day, then a long quiet stretch
The release record is uneven in a way that matters for anyone pinning a version. v7.0.3 and v7.0.4 were both tagged on 2025-02-05, hours apart, which reads as a fix that needed re-cutting rather than a feature pair. The next tag, v7.0.5, is dated 2026-08-14, and the last push carries that same date, so the repository is on its newest tag. The default branch is named master, not main, which is why the coverage badge in the header is pinned to branch=master. Distribution runs to two registries at once, with badges for npm and for the JSR scope @runtypes/runtypes, so a consumer can take the library from either and get the same version line. Nothing in the repository describes a support window for older versions, which means the practical policy has to be read off those tag dates instead.
Editorial conclusion
runtypes suits codebases where untyped values cross a boundary and the receiving side wants one definition of the shape instead of two. Before depending on it, check three things in your own code: whether anything parses the text of message, since that format is explicitly allowed to change between minor versions, whether you need the code, expected and received fields to be stable across the runtypes you use, and whether your CI can run Deno, because the test recipe needs net and read permissions that a pure validation library has no obvious use for. On the license and packaging side there is nothing to resolve: MIT, distributed through both npm and JSR. On cadence, read the tags rather than the marketing: v7.0.3 and v7.0.4 landed on the same day in February 2025, and v7.0.5 followed on 2026-08-14, the same date as the last push.
Frequently asked questions
Does runtypes run on Deno or only on Node.js?
Both registries are advertised, npm and the JSR scope @runtypes/runtypes, but the development toolchain runs on Deno: the build is deno run -A build.ts, tests are deno test with coverage, and type checking is deno check. The root package.json is private and pins pnpm at 8.12.0 as the package manager, with devDependencies pinned to exact versions.
Why should I not parse the message of a ValidationError?
The message property summarizes the problem overall, and its format is documented as something that may change across minor versions for readability reasons. For handling decisions, including i18n, the guidance is to use the other properties instead, such as code, expected and received on the failure object.
Which parts of a runtypes failure are available for every runtype?
The fields dependable across failures are code, a Failcode that roughly categorizes the problem, expected, the runtype that yielded the failure, and received, the value that caused it. details appears only for complex runtypes such as Object and Array, detail only for Brand and contextual failures, and thrown only where user-provided validation functions are involved, as with Constraint, Parser and InstanceOf.
How do I make a runtype match a TypeScript type I already wrote?
Call .conform<T>() on the runtype with the static type as its argument. A definition that matches passes at compile time, and a mismatch fails, as shown by a required String against an optional bar property marked with @ts-expect-error. The compiler output is verbose and the offending piece appears only partway down the text.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/runtypes-runtypes)Star history chart for your README
[](https://hysenlabs.com/projects/runtypes-runtypes)Was this useful?