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 // 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 }
)