idonttrustlikethat
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
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'
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
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,
})
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
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' }
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) {}
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'
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
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
}
const result = configValidator.validate(config)
if (result.ok) {
const configDictionary: Record<string, string> = result.value
Object.values(configDictionary).forEach(str => str.padStart(2))
}
array
import { array, string } from 'idonttrustlikethat'
const validator = array(string)
validator.validate(['a', 'b']).ok
tuple
import { tuple, string, number } from 'idonttrustlikethat'
const validator = tuple(string, number)
validator.validate(['a', 1]).ok
union
import { union, string, number } from 'idonttrustlikethat'
const stringOrNumber = union(string, number)
validator.validate(10).ok
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
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
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}`)
.and(nonEmpty())
.withError(_ => `The id cannot be the empty string`)
})
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
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
map, filter
import { string } from 'idonttrustlikethat'
const validator = string.filter(str => str.length > 3).map(str => `${str}...`)
const result = validator.validate('1234')
result.ok
result.value
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.
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)
})
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')
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
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
booleanFromString
Validates that a string encodes a boolean and returns the boolean.
import { booleanFromString } from 'idonttrustlikethat'
booleanFromString.validate('true').ok
numberFromString
Validates that a string encodes a number (float or integer) and returns the number.
import { numberFromString } from 'idonttrustlikethat'
numberFromString.validate('123.4').ok
intFromString
Validates that a string encodes an integer and returns the number.
import { intFromString } from 'idonttrustlikethat'
intFromString.validate('123').ok
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 }
)