decoders

Overview

Elegant and battle-tested validation library for type-safe input data for TypeScript.

Example

You write a decoder that describes the shape of data you expect. It looks almost identical to the TypeScript type it produces:

import { array, iso8601, number, object, optional, string } from 'decoders';

const userDecoder = object({
  id: number,
  name: string,
  createdAt: optional(iso8601),
  tags: array(string),
});

Then call .verify() on untrusted input to validate it and get full type inference:

const user = userDecoder.verify(externalData);
//    ^^^^
//    TypeScript will infer this type as:
//    {
//      id: number;
//      name: string;
//      createdAt?: Date;
//      tags: string[];
//    }

If verification fails, you get a clear error message pointing at the problem:

Decoding error:
{
  id: 123,
  name: "Alison Roberts",
  createdAt: "not-a-date",
             ^^^^^^^^^^^^ Must be Date
  tags: ["foo", "bar"],
}

Why decoders?

Decoders is one of the OG runtime validation libraries for TypeScript, in production since 2017. Here's what makes it different:

  • Tiny footprint — ~4 KB gzipped, zero external dependencies, fully tree-shakeable. You only pay for what you use.
  • Runs everywhere — Node.js, browsers, Cloudflare Workers, Bun, etc.
  • Reads like a type definition — No z.string() or z.object(). You write object({ name: string, age: number }) and it looks almost identical to the TypeScript type it produces.
  • Best-in-class error messages — When validation fails, decoders echoes back your actual input data with annotations inlined exactly where and why validation failed. Optimized for human readability.
  • Standard Schema compliant — Works out of the box with any framework that supports Standard Schema.

Getting started

npm install decoders

You must set strict: true in your tsconfig.json for type inference to work correctly:

{
  "compilerOptions": {
    "strict": true
  }
}

How it works

The central building block is the Decoder. A Decoder<T> takes untrusted input and either accepts it (returning a value of type T) or rejects it with a helpful error.

Small decoders compose together like LEGO to build larger ones. In the example above, string, number, and iso8601 are all individual decoders that get combined with object, array, and optional.

Decoder composition

Using a decoder

Call one of these methods on any decoder:

MethodReturnsOn failure
.verify()TThrows an error
.value()T | undefinedReturns undefined
.decode()Result<T>Returns error result

Most of the time, .verify() is what you want.

Composing decoders

Every decoder in this library can be used standalone or composed with others. You can also build your own to match any shape of data.

// Compose decoders to describe complex shapes
const addressDecoder = object({
  street: string,
  city: string,
  zip: regex(/^\d{5}$/, 'Must be 5-digit zip code'),
});

const userDecoder = object({
  name: nonEmptyString,
  email: email,
  address: addressDecoder,
  roles: array(oneOf(['admin', 'editor', 'viewer'])),
});

Error formatting

By default, .verify() uses formatInline, which echoes back the input with errors inlined:

import { array, object, string, number } from 'decoders';

const decoder = array(object({ name: string, age: number }));

decoder.verify([{ name: 'Alice', age: '33' }]);
// Decoding error:
// [
//   {
//     name: "Alice",
//     age: "33",
//          ^^^^ Must be number
//   },
// ]

You can also use formatShort for a compact single-line format:

import { formatShort } from 'decoders';

decoder.verify([{ name: 'Alice', age: '33' }], formatShort);
// Decoding error: Value at keypath '0.age': Must be number

On this page