idonttrustlikethat

Validation for TypeScript

Usage no npm install needed!

<script type="module">
  import idonttrustlikethat from 'https://cdn.skypack.dev/idonttrustlikethat';
</script>

README

idonttrustlikethat

(Used to be named validation.ts but some people struggled to make this .ts node module work in TS and we needed a more serious name)

This module helps validating incoming JSON, Form values, url params, localStorage values, server Environment objects, etc in a concise and type safe manner.
The focus of the lib is on small size and an easy API to add new validations.

Note: This module uses very precise Typescript types. Thus, it is mandatory to at least have the following tsconfig / tsc's compiler options flag: strict: true.

How to

Create a new validation

This library exposes a validator for all primitive and object types so you should usually start from one of these then compose it with extra validations.

Here's how isoDate is defined internally:

import { string, Err, Ok } from 'idonttrustlikethat'

const isoDate = string.and(str => {
  const date = new Date(str)
  return isNaN(date.getTime())
    ? Err(`Expected ISO date, got: ${pretty(str)}`)
    : Ok(date)
})

isoDate.validate('2011-10-05T14:48:00.000Z').ok // true

This creates a new Validator that reads a string then tries to create a Date out of it.

You can also create an optional validation step that wouldn't make sense on its own:

import { string, Err, Ok, array, string } from 'idonttrustlikethat'

// This is essentially a basic filter() but with a nicer, custom error message.
const minSize = (size: number) => <T>(array: T[]) =>
  array.length >= size
    ? Ok(array)
    : Err(`Expected an array with at least ${size} items`)

const bigArray = array(string).and(minSize(100))
bigArray.validate(['1', '2']).ok // false

Note: the minSize validator does exactly that, but for more input types.

If you need to start from any value, you can use the unknown validator that always succeeds.

Deriving the typescript type from the validator type

This can be used with any combination of validators except ones using recursion.

You can get the exact type of a validator's value easily:

import { object, string, number } from 'idonttrustlikethat'

const person = object({
  name: string,
  age: number,
})

type Person = typeof person.T

const person: Person = {
  name: 'Jon',
  age: 80
}

Customize error messages

If you say, use this library to validate a Form data, it's best to assign your error messages directly in the validator so that the proper error messages get accumulated, ready for you to display them.

import { object, string } from 'idonttrustlikethat'

const mandatoryFieldError = 'This field is mandatory'
const mandatoryString = string.withError(_ => mandatoryFieldError)

const formValidator = object({
  name: mandatoryString,
})

// {ok: false, errors: [{path: 'name', message: 'This field is mandatory'}]}
const result = formValidator.validate({})

Perform async checks

You don't! Some "similar" libraries offer this functionality but it's a pretty bad idea. It accumulates concerns inside your validation layer (you now have to pass DB connections, API tokens, etc to what should be dumb validators) and polutes the API signatures (once you go async for a tiny bit, everything now has to be async)

For instance, instead of trying to make a call to the DB to check some unicity constraint inside your validator, instead prepare the call's result before hand then pass that to a function that creates a new validator using that result, for instance:

import {string, object} from 'idonttrustlikethat' 

function makeUserValidator(params: {isEmailKnown: boolean}) {
  const {isEmailKnown} = params

  return object({
    name: string,
    email: string
      .withError(_ => 'The email is mandatory')
      .filter(_ => !isEmailKnown)
      .withError(_ => 'This email is already in use')
  })
}

const isEmailKnown = await db.user.checkIfEmailIsKnown(...)

const validatedUser = makeUserValidator({isEmailKnown}).validate(body)

Exports

Here are all the values this library exposes:

import {
  Err,
  Ok,
  array,
  dictionary,
  errorDebugString,
  intersection,
  union,
  is,
  literal,
  unknown,
  null as vnull,
  number,
  object,
  string,
  boolean,
  tuple,
  undefined,
} from 'idonttrustlikethat'
import {
  isoDate,
  recursion,
  snakeCaseTransformation,
  relativeUrl,
  absoluteUrl,
  url,
  booleanFromString,
  numberFromString,
  intFromString,
  minSize,
  nonEmpty
} from 'idonttrustlikethat'

And all the types:

import {
  Result,
  Err,
  Ok,
  Validation,
  Validator,
  Configuration,
} from 'idonttrustlikethat'

API

validate

Every validator has a validate function which returns a Result (either a {ok: true, value} or a {ok: false, errors}) Errors are accumulated.

import { object, errorDebugString } from 'idonttrustlikethat'

const myValidator = object({})
const result = myValidator.validate(myJson)

if (result.ok) {
  console.log(result.value)
} else {
  console.error(errorDebugString(result.errors))
}

In case of errors, errors contains an Array of { message: string, path: string } where message is a debug error message for developers and path is the path where the error occured (e.g people.0.name)

errorDebugString will give you a complete debug string of all errors, e.g.

At [root / c] Error validating the key. "c" is not a key of {
  "a": true,
  "b": true
}
At [root / c] Error validating the value. Type error: expected number but got string

primitives

import * as v from 'idonttrustlikethat'

v.unknown
v.string
v.number
v.boolean
v.null
v.undefined

v.string.validate(12).ok // false

tagged string/number

Sometimes, a string or a number is not just any string or number but carries extra meaning, e.g: email, uuid, UserId, KiloGram, etc.
Tagging such a primitive as soon as it's being validated can help make the downstream code more robust and better documented.

import { string, object } from 'idonttrustlikethat'

type UserId = string & { __tag: 'UserId' } // Note: You can use any naming convention for the tag.

const userId = string.tagged<UserId>()

const user = object({
  id: userId
})

If you don't use tagged types, it can lead to situations like:

const user = object({
  id: string,
  companyId: string
})

const user = {
  id: '12345678',
  companyId: '7cd3821a-553f-4d26-84f9-88776005612b'
}

function fetchCompanyDetails(companyId: string) {}

// Nothing prevents you from passing the wrong ID "type"
fetchCompanyDetails(user.id)

Using tagged types fixes all these problems while also retaining that type's usefulness as a basic string/number.

literal

import { literal } from 'idonttrustlikethat'

// The only value that can ever pass this validation is the 'X' string literal
const validator = literal('X')

object

import { string, object, union } from 'idonttrustlikethat'

const person = object({
  id: string,
  prefs: object({
    csvSeparator: union(',', ';', '|').optional(),
  }),
})

validator.validate({
  id: '123',
  prefs: {},
}).ok // true

Note that if you validate an input object with extra properties compared to what the validator know, these will be dropped from the output.
This helps keeping a clean object and let us avoid dangerous situations such as:

import { string, object } from 'idonttrustlikethat'

const configValidator = object({
  clusterId: string,
  version: string
})

const config = {
  clusterId: '123',
  version: 'v191',
  extraStuffFromTheServer: 100,
  _metadata: true
}

// Let's imagine what could happen if this kept all non declared properties in the output.
const result = configValidator.validate(config)

if (result.ok) {
  // As far as typescript is concerned, all values are string in the validated object, which let us manipulate it as such, perhaps to pass it some generic utility:  
  const configDictionary: Record<string, string> = result.value

  // But it's a lie, some properties are still found in the object that aren't strings.
  // This will throw an exception when the entire point of validating is to avoid that.
  Object.values(configDictionary).forEach(str => str.padStart(2))
}

array

import { array, string } from 'idonttrustlikethat'

const validator = array(string)

validator.validate(['a', 'b']).ok // true

tuple

import { tuple, string, number } from 'idonttrustlikethat'

const validator = tuple(string, number)

validator.validate(['a', 1]).ok // true

union

import { union, string, number } from 'idonttrustlikethat'

const stringOrNumber = union(string, number)

validator.validate(10).ok // true

Unions of literal values do not have to use literal() but can be passed the values directly:

import {union} from 'idonttrustlikethat'

const bag = union(null, 'hello', true, 33)

discriminatedUnion

Although you could also use union for your discriminated unions, discriminatedUnion is faster and has better error messages for that special case. It will also catch common typos at the type level.
Note that discriminatedUnion only works with object and intersection (of objects) validators. Also, the discriminating property must be either a literal or union of primitives.

import {discriminatedUnion, literal, string} from 'idonttrustlikethat'

const userSending = object({
  type: literal('sending')
})

const userEditing = object({
  type: literal('editing'),
  currentText: string
})

const userChatAction = discriminatedUnion('type', userSending, userEditing)

intersection

import { intersection, object, string, number } from 'idonttrustlikethat'

const object1 = object({ id: string })
const object2 = object({ age: number })
const validator = intersection(object1, object2)

validator.validate({ id: '123', age: 80 }).ok // true

optional, nullable

optional() transforms a validator to allow undefined values.

nullable() transforms a validator to allow undefined and null values, akin to the std lib NonNullable type.

If you must validate a T | null that shouldn't possibly be undefined, you can use union()

import { string } from 'idonttrustlikethat'

const validator = string.nullable()

const result = validator.validate(undefined)

result.ok && result.value // undefined

default

Returns a default value if the validated value was either null or undefined.

import { string } from 'idonttrustlikethat'

const validator = string.default(':(')

const result = validator.validate(undefined)

result.ok && result.value // :(

withError

Sets a custom error message onto the validator.
The validator have decent error messages by default for developers but you will sometimes want to customize these.
Note that the first withError encountering an error wins but a single withError will apply to any error encountered in the chain.

import {object, string} from 'idonttrustlikethat'

const validator = object({
  id: string
    .withError(i => `Expected a string, got ${i}`) // This will activate if the input is not a string or is missing.
    .and(nonEmpty())
    .withError(_ => `The id cannot be the empty string`) // This will activate only if the id is a string but is empty.
})

dictionary

A dictionary is an object where all keys and all values share a common type.

import { dictionary, string, number } from 'idonttrustlikethat'

const validator = dictionary(string, number)

validator.validate({
  a: 1,
  b: 2,
}).ok // true

If you need a partial dictionary, simply type your values as optional:

import { dictionary, string, number, union } from 'idonttrustlikethat'

const validator = dictionary(union('a', 'b', 'c'), number.optional())

validator.validate({
  b: 1
}).ok // true

map, filter

import { string } from 'idonttrustlikethat'

const validator = string.filter(str => str.length > 3).map(str => `${str}...`)

const result = validator.validate('1234')
result.ok // true
result.value // 1234...

and

Unlike map which deals with a validated value and returns a new value, and can return either a validated value or an error.

import { string, Ok, Err } from 'idonttrustlikethat'

const validator = string.and(str =>
  str.length > 3 ? Ok(str) : Err(`No, that just won't do`)
)

then

then allows the chaining of Validators. It can be used instead of and if you already have the Validators ready to be reused.

// Validate that a string is a valid number (e.g, query string param)
const stringToInt = v.string.and(str => {
  const result = Number.parseInt(str, 10)
  if (Number.isFinite(result)) return Ok(result)
  return Err('Expected an integer-like string, got: ' + str)
})

// unix time -> Date
const timestamp = v.number.and(n => {
  const date = new Date(n)
  if (isNaN(date.getTime())) return Err('Not a valid date')
  return Ok(date)
})

const timeStampFromQueryString = stringToInt.then(timestamp)

timeStampFromQueryString.validate('1604341882') // {ok: true, value: Date(...)}

recursion

import { recursion, string, array, object } from 'idonttrustlikethat'

type Category = { name: string; categories: Category[] }

const category = recursion<Category>(self =>
  object({
    name: string,
    categories: array(self),
  })
)

minSize

Ensures an Array, Object, string, Map or Set has a minimum size. You can also use nonEmpty.

import {dictionary, string} from 'idonttrustlikethat'
import {minSize} from 'idonttrustlikethat'

const dictionaryWithAtLeast10Items = dictionary(string, string).and(minSize(10))

isoDate

import { isoDate } from 'idonttrustlikethat'

isoDate.validate('2011-10-05T14:48:00.000Z').ok // true

url

Validates that a string is a valid URL, and returns that string.

import { url, absoluteUrl, relativeUrl } from 'idonttrustlikethat'

absoluteUrl.validate('https://ebay.com').ok // true

booleanFromString

Validates that a string encodes a boolean and returns the boolean.

import { booleanFromString } from 'idonttrustlikethat'

booleanFromString.validate('true').ok // true

numberFromString

Validates that a string encodes a number (float or integer) and returns the number.

import { numberFromString } from 'idonttrustlikethat'

numberFromString.validate('123.4').ok // true

intFromString

Validates that a string encodes an integer and returns the number.

import { intFromString } from 'idonttrustlikethat'

intFromString.validate('123').ok // true

Configuration

A Configuration object can be passed to modify the default behavior of the validators:

Configuration.transformObjectKeys

Transforms every keys of every objects before validating.

import {snakeCaseTransformation} from 'idonttrustlikethat'

const burger = v.object({
  options: v.object({
    doubleBacon: v.boolean,
  }),
})

const ok = burger.validate(
  {
    options: {
      double_bacon: true,
    },
  },
  { transformObjectKeys: snakeCaseTransformation }
)