Skip to content

jmadelaine/ts-guardian

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

66 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

MIT license NPM version

ts-guardian

Functional, composable, reliable type guards! πŸ‘

There are already several great type guard packages, but ts-guardian takes it a step further - a functional approach to creating reliable, composable, type-safe type guards.


Type guards?

A type guard is a function that takes any value, and returns a boolean that determines if the value is compatible with a specified type.

Type guards are used to confirm the structure of data. If your app deals with API responses, objects with optional members, or values that are unknown or change regularly, you'll benefit from ts-guardian.


Core principles

  • Concise, human-readable syntax - Type definitions can be complex. ts-guardian's syntax is minimal, declarative, and reads like a sentence.
  • Composable - Complex types are composed of a finite number of basic types. Similarly, type guards can be composed from other type guards due to ts-guardian's functional approach.
  • Full TypeScript support - Guards will type matching values with an auto-generated type, defined by the type-checking process, which can be implicitly cast to a user-defined type while retaining type-safety.
  • Reliable - No false positives, no assumptions, and no TypeScript type assertions or inaccurate type predicates. ts-guardian is 100% type-safe.

Index


Installation

// npm
npm i ts-guardian

// yarn
yarn add ts-guardian

Usage

The is function

import { is } from 'ts-guardian'

Use is to create type guards. The is function takes a parameter that defines a type, and returns a guard for that type:

const isString = is('string') // guard for 'string'

isString('') // returns true
isString(0) // returns false

Basic types

Pass a type as a string to create guards for basic types:

const isBoolean = is('boolean') // guard for 'boolean'
const isNull = is('null') // guard for 'null'

Basic types are the bread and butter of ts-guardian.

Here's the complete set of keys:

Key Type Equivalent type check
'any' any true (matches anything)
'boolean' boolean typeof <value> === 'boolean'
'bigint' bigint typeof <value> === 'bigint'
'function' Function typeof <value> === 'function'
'null' null <value> === null
'number' number typeof <value> === 'number'
'object' object typeof <value> === 'object'
'string' string typeof <value> === 'string'
'symbol' symbol typeof <value> === 'symbol'
'undefined' undefined <value> === undefined
'unknown' unknown true (matches anything)

When combined with other guards, the any and unknown type guards take precedence. These are useful in complex types where you can specify part of the type as any or unknown, for example, an object member.

Basic type guards will return false for objects created with constructors. For example, is('string')(new String()) returns false. Use isInstanceOf instead.


Union types

Every type guard has an or method which has the same signature as the is function. Use or to create union types:

const isStringOrNumber = is('string').or('number') // guard for 'string | number'

isStringOrNumber('') // returns true
isStringOrNumber(0) // returns true
isStringOrNumber(true) // returns false

Literal types

Pass a number, string, or boolean to the isLiterally function and the orLiterally method to create guards for literal types. You can also pass multiple arguments to create literal union type guards:

import { isLiterally } from 'ts-guardian'

const isCool = isLiterally('cool') // guard for '"cool"'
const is5 = isLiterally(5) // guard for '5'
const isTrue = isLiterally(true) // guard for 'true'
const is1OrTrue = isLiterally(1).orLiterally(true) // guard for '1 | true'
const isCoolOr5OrTrue = isLiterally('cool', 5, true) // guard for '"cool" | 5 | true'

Array types

To check that every element in an array is of a specific type, use the isArrayOf function and the orArrayOf method:

import { is, isArrayOf } from 'ts-guardian'

const isStrArr = isArrayOf('string') // guard for 'string[]'
const isStrOrNumArr = isArrayOf(is('string').or('number')) // guard for '(string | number)[]'
const isStrArrOrNumArr = isArrayOf('string').orArrayOf('number') // guard for 'string[] | number[]'

Note the difference between isArrayOf(is('string').or('number')) which creates a guard for (string | number)[], and isArrayOf('string').orArrayOf('number') which creates a guard for string[] | number[].


Record types

To check that every value in an object is of a specific type, use the isRecordOf function and the orRecordOf method:

import { is, isRecordOf } from 'ts-guardian'

const isStrRecord = isRecordOf('string') // guard for 'Record<PropertyKey, string>'
const isStrOrNumRecord = isRecordOf(is('string').or('number')) // guard for 'Record<PropertyKey, string | number>'
const isStrRecordOrNumRecord = isRecordOf('string').orRecordOf('number') // guard for 'Record<PropertyKey, string> | Record<PropertyKey, number>'

Tuple types

Guards for tuples are defined by passing an array to is:

const isStrNumTuple = is(['string', 'number']) // guard for '[string, number]'

isStrNumTuple(['high']) // returns false
isStrNumTuple(['high', 5]) // returns true

Guards for nested tuples can be defined by passing tuple guards to tuple guards:

const isStrAndNumNumTupleTuple = is(['string', is(['number', 'number'])]) // guard for '['string', [number, number]]'

Object types

The basic type guard for the object type (is('object')) should be used rarely, if at all, due to it matching on null.

Instead, pass an object to is:

const isObject = is({}) // guard for '{}'

isObject({ some: 'prop' }) // returns true
isObject(null) // returns false

To create a guard for an object with specific members, define a guard for each member key:

const hasAge = is({ age: 'number' }) // guard for '{ age: number; }'

hasAge({ name: 'Bob' }) // returns false
hasAge({ name: 'Bob', age: 40 }) // returns true

Intersection types

Every type guard has an and method which has the same signature as the or method. Use and to create intersection types:

const hasXOrY = is({ x: 'any' }).or({ y: 'any' }) // guard for '{ x: any; } | { y: any; }'
const hasXAndY = is({ x: 'any' }).and({ y: 'any' }) // guard for '{ x: any; } & { y: any; }'

hasXOrY({ x: '' }) // returns true
hasXOrY({ y: '' }) // returns true
hasXOrY({ x: '', y: '' }) // returns true
hasXAndY({ x: '' }) // returns false
hasXAndY({ y: '' }) // returns false
hasXAndY({ x: '', y: '' }) // returns true

Instance types

Guards for object instances are defined by passing a constructor object to the isInstanceOf function and the orInstanceOf method:

const isDate = isInstanceOf(Date) // guard for 'Date'

isDate(new Date()) // returns true

const isRegExpOrUndefined = is('undefined').orInstanceOf(RegExp) // guard for 'RegExp | undefined'

isRegExpOrUndefined(/./) // returns true
isRegExpOrUndefined(new RegExp('.')) // returns true
isRegExpOrUndefined(undefined) // returns true

This works with user-defined classes too:

class Person {
  name: string
  constructor(name: string) {
    this.name = name
  }
}

const steve = new Person('Steve')

const isPerson = isInstanceOf(Person) // guard for 'Person'

isPerson(steve) // returns true

User-defined types

Consider the following type and its guard:

type Book = {
  title: string
  author: string
}

const isBook = is({
  title: isString,
  author: isString,
})

If isBook returns true for a value, that value will be the primitive-based type:

{
  title: string
  author: string
}

Ideally, we want to type the value as Book, while avoiding type assertions and user-defined type predicates.

One way is with a parse function that utilizes TypeScript's implicit casting:

const parseBook = (input: any): Book | undefined => {
  return isBook(input) ? input : undefined
}

TypeScript will complain if the type predicate returned from isBook is not compatible with the Book type. This function is type-safe, but defining these functions over and over is a little tedious.

Instead, you can call the parserFor function:

import { parserFor } from 'ts-guardian'

const parseBook = parserFor<Book>(isBook)

The parserFor function takes a guard, and returns a function you can use to parse values.

This function acts in the same way as the previous parseBook function. It takes a value and passes it to the guard. If the guard matches, it returns the value typed as the supplied user-defined type. If the guard does not match, the function returns undefined:

const book = {
  title: 'Odyssey',
  author: 'Homer',
}

const film = {
  title: 'Psycho',
  director: 'Alfred Hitchcock',
}

parseBook(book) // returns book as type 'Book'
parseBook(film) // returns undefined

The parserFor function is also type-safe. TypeScript will complain if you try to create a parser for a user-defined type that isn't compatible with the supplied type guard:

const parseBook = parserFor<Book>(isBook) // Fine

const parseBook = parserFor<Book>(isString) // TypeScript error - type 'string' is not assignable to type 'Book'

Composition

Guards can be composed from existing guards:

const isString = is('string') // guard for 'string'
const isStringOrUndefined = isString.or('undefined') // guard for 'string | undefined'

You can even pass guards into or:

const isStrOrNum = is('string').or('number') // guard for 'string | number'
const isNullOrUndef = is('null').or('undefined') // guard for 'null | undefined'

// guard for 'string | number | null | undefined'
const isStrOrNumOrNullOrUndef = isStrOrNum.or(isNullOrUndef)

Assertion

Use the assertThat function to throw an error if a value does not match against a guard:

import { is, assertThat } from 'ts-guardian'

const value = getSomeUnknownValue()

// Throws an error if type of value is not 'string'
// Error message: Type of 'value' does not match type guard.
assertThat(value, is('string'))

// Otherwise, type of value is 'string'
value.toUpperCase()

You can optionally pass an error message to assertThat:

import { isUser } from '../myTypeGuards/isUser'

assertThat(value, isUser, 'Value is not a user!')

Convenience guards

There are a bunch of simple guards you'll tend to use frequently. ts-guardian exports these as convenience guards to make things easier:

Guard Type Equivalent to
isBoolean boolean is('boolean')
isBooleanOrNull boolean | null is('boolean').or('null')
isBooleanOrUndefined boolean | undefined is('boolean').or('undefined')
isBigint bigint is('bigint')
isBigintOrNull bigint | null is('bigint').or('null')
isBigintOrUndefined bigint | undefined is('bigint').or('undefined')
isFunction Function is('function')
isFunctionOrNull Function | null is('function').or('null')
isFunctionOrUndefined Function | undefined is('function').or('undefined')
isNull null is('null')
isNullOrUndefined null | undefined is('null').or('undefined')
isNumber number is('number')
isNumberOrNull number | null is('number').or('null')
isNumberOrUndefined number | undefined is('number').or('undefined')
isString string is('string')
isStringOrNull string | null is('string').or('null')
isStringOrUndefined string | undefined is('string').or('undefined')
isSymbol symbol is('symbol')
isSymbolOrNull symbol | null is('symbol').or('null')
isSymbolOrUndefined symbol | undefined is('symbol').or('undefined')
isUndefined undefined is('undefined')

Reliable type guards

ts-guardian provides type-safe type guards.

Consider the following problem:

Problem

We fetch some data from an API. We expect the data to contain information about the current user. How can we guarantee the data is of the correct User type before we use it in our app?

Here is our User type:

type User = {
  id: number
  name: string
  email?: string
  phone?: {
    primary?: string
    secondary?: string
  }
}

Solution 1 - User-defined type guards πŸ‘Ž

With TypeScript's user-defined type guards, we could write an isUser function to confirm the value is of type User. It would probably look something like this:

// Returns user-defined type guard `input is User`
const isUser = (input: any): input is User => {
  const u = input as User
  return (
    typeof u === 'object' &&
    u !== null &&
    typeof u.id === 'number' &&
    typeof u.name === 'string' &&
    (typeof u.email === 'string' || u.email === undefined) &&
    ((typeof u.phone === 'object' &&
      u.phone !== null &&
      (typeof u.phone.primary === 'string' || u.phone.primary === undefined) &&
      (typeof u.phone.secondary === 'string' || u.phone.secondary === undefined)) ||
      u.phone === undefined)
  )
}

Not pretty, but it works!

Apart from being hard to read and harder to reason about, this function seems to get the job done. We have type safety around the User type. Great!

But what if we did this instead:

const isUser = (input: any): input is User => {
  return typeof input === 'object'
}

Clearly this function is not enough to confirm that input is of type User, but TypeScript doesn't complain at all, because type predicates are effectively type assertions.

By saying to TypeScript "if I return true, consider input to be of type User", we lose type safety, and introduce potential runtime errors into our app. 😫

There is no connection between the result of isUser and the compatibility of the type of input with the User type, other than the assumption that our (obviously error-free) boolean logic accurately defines the type we are asserting. Sounds reliable.

So how can we guarantee that a value is compatible with our user-defined type?

Let's try something else...

Solution 2 - Primitive-based type guards πŸ‘

The solution is that we make no assumptions that the value is a user-defined type.

Instead, we define a primitive-based type of what a User object looks like, and let TypeScript determine whether this primitive-based type is compatible with the User type:

A primitive-based type is a type constructed from only primitive TypeScript types (string, number, undefined, any, etc...).

import { is, isStringOrUndefined, isUndefined } from 'ts-guardian'

// We make no assumptions that the data is a user-defined type
const isUser = is({
  id: 'number',
  name: 'string',
  email: isStringOrUndefined,
  phone: isUndefined.or({
    primary: isStringOrUndefined,
    secondary: isStringOrUndefined,
  }),
})

Not only is this much more readable, but instead of isUser returning the type predicate input is User, it now returns a primitive-based type predicate that gets auto-generated from our type checking, so it's 100% accurate.

In this case, the type predicate looks like:

// Type predicate for our primitive-based type
input is {
    id: number;
    name: string;
    email: string | undefined;
    phone: {
        primary: string | undefined;
        secondary: string | undefined;
    } | undefined;
}

It's now up to TypeScript to tell us if this type is compatible with the User type:

// TypeScript complains if the primitive-based type predicate is not compatible with 'User'
const parseUser = parserFor<User>(isUser)

If the type predicate from isUser is not compatible with the User type, then we get a TypeScript compiler error telling us this. πŸŽ‰

Not only that, but the syntax is clean, concise, and readable. Nice! 😎


About

Functional type guarding

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published