Home

Awesome

to-typed

Type-guards, casts and converts unknown values into typed values.

Installation

npm install to-typed

Introduction

This package provides 3 interrelated classes: Cast, Guard and Convert.

Cast

The base class of Guard and Convert. It is a wrap around a cast function that takes an unknown value and returns a Maybe:

cast: (value: unknown) => Maybe<T>

If the cast succeeds, the function returns just the casted value, otherwise it returns nothing.

Cast and its derived classes are designed to make operations chainable in a functional/declarative way:

console.log(Convert
    .toArrayWhere(Cast
        .asString
        .if(str => str.length)
    )
    .map(arr => arr.join(' '))
    .convert([1, null, 'hello', '', 'world', {}, true])
) // "1 hello world true"

Cast factory methods start with the as prefix, such as asNumber or asUnknown.

Guard

A wrap around a guard function that takes an unknown value and returns a boolean indicating whether the input value has the expected type:

guard: (input: unknown) => input is T

It implements the cast method by returning just the input value if it has the expected type, or nothing otherwise:

value => guard(value) ? Maybe.just(value) : Maybe.nothing()

Guard factory methods start with the is prefix, such as isEnum or isBoolean.

Convert

A wrap around a convert function that takes an unknown value and returns a typed value:

convert: (value: unknown) => T

It implements the cast method by always returning just the converted value:

value => Maybe.just(convert(value))

Convert factory methods start with the to prefix, such as toFinite or toString.

Remarks

Note that Guard and Convert are complementary subclasses of Cast in the sense that Guard cannot provide an alternative to the input value, while Convert must provide one. The base class Cast lies in the middle by including both possibilities.

A Guard can produce a Cast by calling some value mapping method:

const guard = Guard.is({ value: Guard.isUnknown }); // Guard<{ value: unknown }>
const cast = guard.map(obj => obj.value).asInteger; // Cast<number>

And a Cast can produce a Convert by providing a default value:

const convert = cast.if(x => x > 0).else(1); // Convert<number>
console.log(convert.convert({ value: '33.3'})); // 33

Quick Start

 

import { Guard, Cast, Convert } from "to-typed"

// ---------------- Type guarding ----------------

// Create a `Guard` based on an object, which may include other guards
const guard = Guard.is({
    integer: Guard.isInteger,
    number: 0,
    boolean: false,
    tuple: [20, 'default', false] as const,
    arrayOfNumbers: Guard.isArrayOf(Guard.isFinite),
    even: Guard.isInteger.if(n => n % 2 === 0),
    object: {
        union: Guard.some(
            Guard.isConst(null),
            Guard.isString,
            Guard.isNumber
        ),
        intersection: Guard.every(
            Guard.is({ int: 0 }),
            Guard.is({ str: "" })
        )
    }
})

const valid: unknown = {
    integer: 123,
    number: 3.14159,
    boolean: true,
    tuple: [10, 'hello', true],
    arrayOfNumbers: [-1, 1, 2.5, Number.MAX_VALUE],
    even: 16,
    object: {
        union: null,
        intersection: { int: 100, str: 'good bye' }
    }
}

if (guard.guard(valid)) {
    // `valid` is now fully typed
    console.log(valid.object.intersection.int); // 100
}

// Alternatively, the base class' `cast` method can be used. Since this is
// just a `Guard`, no casting or cloning will actually occur.
const maybe = guard.cast(valid);

if (maybe.hasValue) {
    // In this context, `maybe.value` is available and fully typed, and it
    // points to the same instance as `valid`.
    console.log(maybe.value.object.intersection.int); // 100
}

// Or equivalently...
maybe.read(value => console.log(value.object.intersection.int)); // 100

// ---------------- Type casting / converting ----------------

// Create a `Convert` based on a sample value, from which the default
// values will also be taken if any cast fails.
const converter = Convert.to({
    integer: Convert.toInteger(1),
    number: 0,
    string: '',
    boolean: false,
    trueIfTruthyInput: Convert.toTruthy(),
    tuple: [0, 'default', false] as const,
    arrayOfInts: Convert.toArrayOf(Convert.to(0)),
    percentage: Convert.toFinite(.5).map(x => Math.round(x * 100) + '%'),
    enum: Convert.toEnum('zero', 'one', 'two', 'three'),
    object: {
        originalAndConverted: Convert.all({
            original: Convert.id,
            converted: Convert.to('')
        }),
        strictNumberOrString: Guard.isNumber.or(Convert.to('')),
        relaxedNumberOrString: Cast.asNumber.or(Convert.to(''))
    }
})

console.log(converter.convert({ excluded: 'exclude-me' }))
// {
//     integer: 1,
//     number: 0,
//     string: '',
//     boolean: false,
//     trueIfTruthyInput: false,
//     tuple: [ 0, 'default', false ],
//     arrayOfInts: [],
//     percentage: '50%',
//     enum: 'zero',
//     object: {
//         originalAndConverted: { original: undefined, converted: '' },
//         strictNumberOrString: '',
//         relaxedNumberOrString: ''
//     }
// }

console.log(converter.convert({
    integer: 2.99,
    number: '3.14',
    string: 'hello',
    boolean: 'true',
    trueIfTruthyInput: [],
    tuple: ['10', 3.14159, 1, 'exclude-me'],
    arrayOfInts: ['10', 20, '30', false, true],
    percentage: ['0.33333'],
    enum: 'two',
    object: {
        originalAndConverted: 12345,
        strictNumberOrString: '-Infinity',
        relaxedNumberOrString: '-Infinity'
    }
}))
// {
//     integer: 3,
//     number: 3.14,
//     string: 'hello',
//     boolean: true,
//     trueIfTruthyInput: true,
//     tuple: [ 10, '3.14159', true ],
//     arrayOfInts: [ 10, 20, 30, 0, 1 ],
//     percentage: '33%',
//     enum: 'two',
//     object: {
//         originalAndConverted: { original: 12345, converted: '12345' },
//         strictNumberOrString: '-Infinity',
//         relaxedNumberOrString: -Infinity
//     }
// }