io-ts-extra

Adds pattern matching, optional properties, and several other helpers and types, to io-ts.

Usage no npm install needed!

<script type="module">
  import ioTsExtra from 'https://cdn.skypack.dev/io-ts-extra';
</script>

README

io-ts-extra

Adds pattern matching, optional properties, and several other helpers and types, to io-ts.

Node CI codecov npm version

Features

  • Pattern matching
  • Optional properties
  • Advanced refinement types
  • Regex types
  • Parser helpers

Contents

Motivation

Comparison with io-ts

The maintainers of io-ts are (rightly) strict about keeping the API surface small and manageable, and the implementation clean. As a result, io-ts is a powerful but somewhat low-level framework.

This library implements some higher-level concepts for use in real-life applications with complex requirements - combinators, utilities, parsers, reporters etc.

Comparison with io-ts-types

io-ts-types exists for similar reasons. This library will aim to be orthogonal to io-ts-types, and avoid re-inventing the wheel by exposing types that already exist there.

io-ts-extra will also aim to provide more high-level utilities and combinators than pre-defined codecs.

Philosophically, this library will skew slightly more towards pragmatism at the expense of type soundness - for example the stance on t.refinement vs t.brand.

This package is also less mature. It's currently in v0, so will have a different release cadence than io-ts-types.

Documentation

Pattern matching

match

Match an object against a number of cases. Loosely based on Scala's pattern matching.

Example
// get a value which could be a string or a number:
const value = Math.random() < 0.5 ? 'foo' : Math.random() * 10
const stringified = match(value)
 .case(String, s => `the message is ${s}`)
 .case(7, () => 'exactly seven')
 .case(Number, n => `the number is ${n}`)
 .get()

Under the hood, io-ts is used for validation. The first argument can be a "shorthand" for a type, but you can also pass in io-ts codecs directly for more complex types:

Example
// get a value which could be a string or a number:
const value = Math.random() < 0.5 ? 'foo' : 123
const stringified = match(value)
 .case(t.number, n => `the number is ${n}`)
 .case(t.string, s => `the message is ${s}`)
 .get()

you can use a predicate function or t.refinement for the equivalent of scala's case x: Int if x > 2:

Example
// value which could be a string, or a real number in [0, 10):
const value = Math.random() < 0.5 ? 'foo' : Math.random() * 10
const stringified = match(value)
 .case(Number, n => n > 2, n => `big number: ${n}`)
 .case(Number, n => `small number: ${n}`)
 .default(x => `not a number: ${x}`)
 .get()
Example
// value which could be a string, or a real number in [0, 10):
const value = Math.random() < 0.5 ? 'foo' : Math.random() * 10
const stringified = match(value)
 .case(t.refinement(t.number, n => n > 2), n => `big number: ${n}`)
 .case(t.number, n => `small number: ${n}`)
 .default(x => `not a number: ${x}`)
 .get()

note: when using predicates or t.refinement, the type being refined is not considered exhaustively matched, so you'll usually need to add a non-refined option, or you can also use .default as a fallback case (the equivalent of .case(t.any, ...))

Params
name description
obj the object to be pattern-matched

matcher

Like @see match but no object is passed in when constructing the case statements. Instead .get is a function into which a value should be passed.

Example
const Email = t.type({sender: t.string, subject: t.string, body: t.string})
const SMS = t.type({from: t.string, content: t.string})
const Message = t.union([Email, SMS])
type Message = typeof Message._A

const content = matcher<MessageType>()
  .case(SMS, s => s.content)
  .case(Email, e => e.subject + '\n\n' + e.body)
  .get({from: '123', content: 'hello'})

expect(content).toEqual('hello')

The function returned by .get is stateless and has no this context, you can store it in a variable and pass it around:

Example
const getContent = matcher<Message>()
  .case(SMS, s => s.content)
  .case(Email, e => e.subject + '\n\n' + e.body)
  .get

const allMessages: Message[] = getAllMessages();
const contents = allMessages.map(getContent);

Shorthand

The "shorthand" format for type specifications maps to io-ts types as follows:

codecFromShorthand

Gets an io-ts codec from a shorthand input:

|shorthand|io-ts type| |-|-| |String, Number, Boolean|t.string, t.number, t.boolean| |Literal raw strings, numbers and booleans e.g. 7 or 'foo'|t.literal(7), t.literal('foo') etc.| |Regexes e.g. /^foo/|see regexp| |null and undefined|t.null and t.undefined| |No input (not the same as explicitly passing undefined)|t.unknown| |Objects e.g. { foo: String, bar: { baz: Number } }|t.type(...) e.g. t.type({foo: t.string, bar: t.type({ baz: t.number }) }) |Array|t.unknownArray| |Object|t.object| |One-element arrays e.g. [String]|t.array(...) e.g. t.array(t.string)| |Tuples with explicit length e.g. [2, [String, Number]]|t.tuple e.g. t.tuple([t.string, t.number])| |io-ts codecs|unchanged| |Unions, intersections, partials, tuples with more than 3 elements, and other complex types|not supported, except by passing in an io-ts codec|

Codecs/Combinators

sparseType

Can be used much like t.type from io-ts, but any property types wrapped with optional from this package need not be supplied. Roughly equivalent to using t.intersection with t.type and t.partial.

Example
const Person = sparseType({
  name: t.string,
  age: optional(t.number),
})

// no error - `age` is optional
const bob: typeof Person._A = { name: 'bob' }
Params
name description
props equivalent to the props passed into t.type
Returns

a type with props field, so the result can be introspected similarly to a type built with t.type or t.partial - which isn't the case if you manually use t.intersection([t.type({...}), t.partial({...})])

optional

unions the passed-in type with null and undefined.

mapper

A helper for building "parser-decoder" types - that is, types that validate an input, transform it into another type, and then validate the target type.

Example
const StringsFromMixedArray = mapper(
  t.array(t.any),
  t.array(t.string),
  mixedArray => mixedArray.filter(value => typeof value === 'string')
)
StringsFromMixedArray.decode(['a', 1, 'b', 2]) // right(['a', 'b'])
StringsFromMixedArray.decode('not an array')   // left(...)
Params
name description
from the expected type of input value
to the expected type of the decoded value
map transform (decode) a from type to a to type
unmap transfrom a to type back to a from type

parser

A helper for parsing strings into other types. A wrapper around mapper where the from type is t.string.

Example
const IntFromString = parser(t.Int, parseFloat)
IntFromString.decode('123')          // right(123)
IntFromString.decode('123.4')        // left(...)
IntFromString.decode('not a number') // left(...)
IntFromString.decode(123)            // left(...)
Params
name description
type the target type
decode transform a string into the target type
encode transform the target type back into a string

strict

Like t.type, but fails when any properties not specified in props are defined.

Example
const Person = strict({name: t.string, age: t.number})

expectRight(Person.decode({name: 'Alice', age: 30}))
expectLeft(Person.decode({name: 'Bob', age: 30, unexpectedProp: 'abc'}))
expectRight(Person.decode({name: 'Bob', age: 30, unexpectedProp: undefined}))
Params
name description
props dictionary of properties, same as the input to t.type
name optional type name

note:

  • additional properties explicitly set to undefined are permitted.
  • internally, sparseType is used, so optional properties are supported.

narrow

Like io-ts's refinement type but:

  1. Not deprecated (see https://github.com/gcanti/io-ts/issues/373)
  2. Passes in Context to the predicate argument, so you can check parent key names etc.
  3. Optionally allows returning another io-ts codec instead of a boolean for better error messages.
Example
const CloudResources = narrow(
  t.type({
    database: t.type({username: t.string, password: t.string}),
    service: t.type({dbConnectionString: t.string}),
  }),
  ({database}) => t.type({
    service: t.type({dbConnectionString: t.literal(`${database.username}:${database.password}`)}),
  })
)

const valid = CloudResources.decode({
  database: {username: 'user', password: 'pass'},
  service: {dbConnectionString: 'user:pass'},
})
// returns a `Right`

const invalid = CloudResources.decode({
  database: {username: 'user', password: 'pass'},
  service: {dbConnectionString: 'user:wrongpassword'},
})
// returns a `Left` - service.dbConnectionString expected "user:pass", but got "user:wrongpassword"

validationErrors

Similar to io-ts's PathReporter, but gives slightly less verbose output.

Params
name description
validation Usually the result of calling .decode with an io-ts codec.
typeAlias io-ts type names can be verbose. If the type you're using doesn't have a name,
you can use this to keep error messages shorter.

regexp

A type which validates its input as a string, then decodes with String.prototype.match, succeeding with the RegExpMatchArray result if a match is found, and failing if no match is found.

Example
const AllCaps = regexp(/\b([A-Z]+)\b/)
AllCaps.decode('HELLO')  // right([ 'HELLO', index: 0, input: 'HELLO' ])
AllCaps.decode('hello')  // left(...)
AllCaps.decode(123)      // left(...)

instanceOf

Validates that a value is an instance of a class using the instanceof operator

Example
const DateType = instanceOf(Date)
DateType.is(new Date())  // right(Date(...))
DateType.is('abc')       // left(...)