"justus" (latin, adj.): "proper" or "correct".
JUSTUS is a very simple library for validating JavaScript objects and properly annotating them with TypeScript types.
It focuses in providing an easy and terse syntax to define a simple schema, used to ensure that an object is correct and from which proper typing can be inferred.
- Quick Start
- Inferring Types
- Validators
- Extra Validators
- Validation Options
- A (slightly more) complex example
- Generating DTS files
- Copyright Notice
- License
You can use JUSTUS in your projects quite simply: import, write a schema and validate. For example:
import { number, object, string, validate } from 'justus'
// Create a validator, validating _objects_ with a specific schema
const validator = object({
// The "foo" property in the objects to validate must be a "string"
// with a minimum length of one character
foo: string({ minLength: 1 }),
// The "bar" property in the objects to validate must be a "number"
bar: number,
// Always use `as const`: it correctly infers types for constants, tuples, ...
} as const)
// Use the validator to validate the object specified as the second argument
const validated = validate(validator, { foo: 'xyz', bar: 123 })
validated.foo // <-- its type will be "string"
validated.bar // <-- its type will be "number"
Easy, terse, ultimately very readable... And all types are inferred!
The validate
function (or anywhere a validation is needed) can accept a
shorthand inline syntax. From our example above:
import { number, string, validate } from 'justus'
const validated = validate({
foo: string({ minLength: 1 }),
bar: number,
} as const, {
foo: 'xyz',
bar: 123,
})
... you get the drill! See below in each validator for their shorthand syntax.
It is sometimes useful to have access to the inferred types produced by the
validation function. The InferValidation
type does just that:
const myTypeValidator = object({
/* a required string, with minimum length of 1 character */
foo: string({ minLength: 1 }),
/* an optional number, defaulted to 12345 */
bar: optional(number, 12345),
/* an optional date, without default */
baz: optional(date),
} as const)
type MyValidatedType = InferValidation<typeof myTypeValidator>
// here "MyValidatedType" will have the following shape:
// {
// foo: string, // plain "string"
// bar: number, // optional _with_ a default
// baz?: Date | undefined , // optional _without_ a default
// }
Similarly, it might be useful to have access to a minimal type required as a validation input. This obviously differs from the input for several reasons, like:
- optionals with default: on input we should represent
optional(type, default)
astype
, but on input this should betype | undefined
- type conversions: the
date
validator converts numbers or strings always intoDate
objects, but the input type should beDate | string | number
The InferInput
helps in this case:
const myTypeValidator = object({
/* a required string, with minimum length of 1 character */
foo: string({ minLength: 1 }),
/* an optional number, defaulted to 12345 */
bar: optional(number, 12345),
/* an optional date, without default */
baz: optional(date),
} as const)
type MyInputType = InferInput<typeof myTypeValidator>
// here "MyInputType" will have the following shape:
// {
// foo: string, // plain string
// bar?: number | undefined, // optional, as it has a default
// baz?: Date | string | number | undefined, // optional, also parsed from string, number
// }
String validators are created using the string
function:
import { string } from 'justus'
const s1 = string // validates any string
const s2 = string({ minLength: 1 }) // validate non empty strings
minLength?: number
: The minimum length of a validstring
maxLength?: number
: The maximum length of a validstring
pattern?: RegExp
: ARegExp
enforcing a particular pattern for a validstring
Type branding can be used for string primitives. For example:
import { string, validate } from 'justus'
type UUID = string & { __brand_uuid: never }
const uuidValidator = string<UUID>({
pattern: /^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$/,
minLength: 36,
maxLength: 36,
})
const value = validate(uuidValidator, 'C274773D-1444-41E1-9D3A-9F9D584FE8B5')
value = 'foo' // <- will fail, as "foo" is a `string`, while "value" is a `UUID`
Sometimes it might be useful to declare the branding of a string without
recurring to an external type. We can easily do so by adding the brand
property our string constraints. Following the example above:
import { string, validate } from 'justus'
const uuidValidator = string({
pattern: /^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$/,
minLength: 36,
maxLength: 36,
brand: 'uuid',
})
const value = validate(uuidValidator, 'C274773D-1444-41E1-9D3A-9F9D584FE8B5')
value = 'foo' // <- fail! the type of "value" is "string & __brand_uuid: never"
The shorthand syntax for string validators is simply string
. For example:
import { object, string } from 'justus'
const validator = object({
foo: string, // yep, no parenthesis, just "string"
})
Number validators are created using the number
function:
import { number } from 'justus'
const n1 = number // validates any number
const n2 = number({ minimum: 123 }) // validate numbers 123 and greater
const n3 = number({ fromString: true }) // parse strings like "12.34" or "0x0CAFE"
multipleOf?: number
: The value for which anumber
must be multiple of for it to be validmaximum?: number
: The inclusive maximum value for a validnumber
minimum?: number
: The inclusive minimum value for a validnumber
exclusiveMaximum?: number
: The exclusive maximum value for a validnumber
exclusiveMinimum?: number
: The exclusive minimum value for a validnumber
allowNaN?: boolean
: Whether to allowNaN
or not (default:false
)fromString?: boolean
: Whether to parse numbers from strings or not (default:false
)
Type branding can be used for number primitives. For example:
import { number, validate } from 'justus'
type Price = number & { __brand_price: never }
const priceValidator = number<Price>({
multipleOf: 0.01, // cents, anyone? :-)
minimum: 0, // no negative prices, those are _discounts_
})
const value = validate(priceValidator, 123.45)
value = 432 // <- will fail, as 432 is a `number`, while "value" is a `Price`
Sometimes it might be useful to declare the branding of a number without
recurring to an external type. We can easily do so by adding the brand
property our number constraints. Following the example above:
import { number, validate } from 'justus'
const priceValidator = number({
multipleOf: 0.01, // cents, anyone? :-)
minimum: 0, // no negative prices, those are _discounts_
brand: 'price',
})
const value = validate(priceValidator, 123.45)
value = 432 // <- fail! the type of "value" is "number & __brand_price: never"
The shorthand syntax for number validators is simply number
. For example:
import { number, object } from 'justus'
const validator = object({
foo: number, // yep, no parenthesis, just "number"
})
The boolean validator is represented by the boolean
constant:
import { boolean, object } from 'justus'
const validator = object({
foo: boolean, // it's a constant, no options!
})
To validate the string true
or false
as booleans, simply create the
validator with the fromString
option (defaults to false
):
import { boolean, object, validate } from 'justus'
const validator = object({
foo: boolean({ fromString: true }),
})
// Here myValue can be a boolean or the string "true" or "false" (case insensitive)
const bool = validate(validator, myValue)
Cosntant validators are created using the constant
function:
import { constant } from 'justus'
const c1 = constant('foo') // validates the `string` constant "foo"
const c2 = constant(12345) // validates the `number` constant 12345
const c3 = constant(false) // validates the `boolean` constant `false`
const c4 = constant(null) // validates the `null` constant
The constant validator requires a string
, number
, boolean
or null
constant.
The shorthand syntax for constant validators is simply its value. For example:
import { object, validate } from 'justus'
const validator = object({
foo: 'foo', // the string "foo"
bar: 12345, // the number 12345
baz: false, // the boolean false
nil: null, // the null constant
} as const) // yep, don't forget "as const" to infer types correctly
const result = validate(validator, something)
result.foo // <- its type will be `"foo"` (or "string" if you didn't use "as const")
result.bar // <- its type will be `12345` (or "number" if you didn't use "as const")
result.baz // <- its type will be `false` (or "boolean" if you didn't use "as const")
result.nil // <- its type will be `null` (or "any" if you didn't use "as const")
The any validator is represented by the any
constant:
import { any, object, validate } from 'justus'
const validator = object({
foo: any // it's a constant, no options!
})
const result = validate(validator, something)
result.foo // <- its type will be `any`
Array validators are created using the array
or arrayOf
functions:
import { array, arrayOf, number, string } from 'justus'
const a1 = array // validates any array
const a2 = array({ maxItems: 10, items: string }) // array of strings
const a3 = arrayOf(number) // array of numbers
maxItems?: number
: The maximum number of elements a validArray
minItems?: number
: The minimum number of elements a validArray
uniqueItems?: boolean
: Indicates if theArray
's elements must be uniqueitems?: V
: The type of each individual item in theArray
*/
The shorthand syntax for string validators is simply array
. For example:
import { array, object } from 'justus'
const validator = object({
foo: array, // validate any array, of any length, containing anything
})
The arrayOf
function can also be considered a shorthand of the full
array({ items: ... })
. For example the two following declarations are
equivalent:
import { array, arrayOf, string } from 'justus'
const a1 = array({ items: string })
const a2 = arrayOf(string) // same as above, just more readable
Date validators are created using the date
function:
import { date } from 'justus'
const d1 = date // validates any date
const d2 = date({ format: 'iso' }) // validate ISO dates
NOTE: Date validators also convert dates (in string format), or timestamps (milliseconds from the epoch) into proper
Date
instances.
format?: 'iso' | 'timestamp'
: The format for dates,iso
for ISO Dates (as outlined in RFC 3339) ortimestamp
for the number of milliseconds since the epochfrom?: Date
: The earliest value a date can haveuntil?: Date
: The latest value a date can have
The shorthand syntax for number validators is simply date
. For example:
import { date, object } from 'justus'
const validator = object({
foo: date, // anything that can be converted to `Date` will be!
})
A tuple is (by definition) a finite ordered list (sequence) of elements.
Tuple validators are created using the tuple
function:
import { tuple, string, number, boolean } from 'justus'
// Validates 3 elements tuple: (in order) a string, a number and a boolean
const t1 = tuple([ string, number, boolean ])
// Validates a tuple whose first element is a string, followed by zero or more
// numbers, and wholse last element is a boolean
const t2 = tuple([ string, ...number, boolean ]) // yay! rest parameters!
As shown above, any Validator
(or one of its shorthands) can be used as a
rest parameter implying zero or more elements of the specified kind.
A more complex example:
import { number, object, string, tuple, validate } from 'justus'
const myObject = object({
version: number,
title: string,
})
// This is the silliest tuple ever written, but outlines our intentions:
const sillyTuple = tuple([ 'start', ...myObject, 'end' ] as const)
// Validate using our tuple:
validate(sillyTuple, [
'start', // yep, a constant
{ version: 1, title: 'Hello world' },
{ version: 2, title: 'Foo, bar and baz' },
'end', // the last
])
As seen in the examples above, object validators are created using the
object
function:
import { object, string, number } from 'justus'
const o1 = object // any object (excluding null - darn JavaScript)
const o2 = object({
foo: string, // any string
bar: number, // any number
baz: 'Hello, world!', // the constant "Hello, world!"
} as const)
The shorthand syntax for object validators is simply object
. For example:
import { arrayOf, object } from 'justus'
const validator = arrayOf(object) // won't validate if the array has numbers, strings, ...
Sometimes it's necessary to allow additional properties in an object.
Destructuring ...allowAdditionalProperties
in an objects does the trick!
import { allowAdditionalProperties, boolean, number, object, string, validate } from 'justus'
const o1 = object({
foo: string, // any string
bar: number, // any number
...allowAdditionalProperties, // any other key will be "any"
})
const result1 = validate(o1, something)
result1.foo // <-- this will be a "string"
result1.bar // <-- this will be a "number"
result1.baz // <-- additional property, this will be "any"
// additional properties with a type
const o2 = object({
foo: string, // any string
bar: number, // any number
...allowAdditionalProperties(boolean), // any other key will be "boolean"
})
const result2 = validate(o2, something)
result2.foo // <-- this will be a "string"
result2.bar // <-- this will be a "number"
result2.baz // <-- additional property, this will be "boolean"
Here allowAdditionalProperties
is also a function, which can take some
parameters to configure its behaviour:
...allowAdditionalProperties
: default shorthand, allows additional properties and will infer theany
type for them....allowAdditionalProperties()
: as a function, and same as above, it allows additional properties and will infer theany
type for them....allowAdditionalProperties(true)
: as a function, and same as above, it allows additional properties and will infer theany
type for them....allowAdditionalProperties(false)
: as a function, it forbids any additional property in objects, useful when extending objects (see below)...allowAdditionalProperties(... type ...)
: as a function, it allows additional properties in objects and ensures their type is correct
Simply destructure one into another. For example:
import { object, string, number, boolean } from 'justus'
const o1 = object({
foo: string, // any string
bar: number, // any number
})
const o2 = object({
...o1, // anything part of "o1" will be here as well!
bar: boolean, // here "bar" is no longer a number, but a boolean
baz: number, // add the "baz" property as a number
} as const)
A slightly more complex scenario arises when considering additional properties in the base object, but forcedly forbidding them in an extend one.
To do so, simply override in the extended object as follows:
Simply destructure one into another. For example:
import { allowAdditionalProperties, boolean, number, object, string } from 'justus'
const o1 = object({
foo: string, // any string
bar: number, // any number
...allowAdditionalProperties(boolean), // any other property is a boolean
})
const o2 = object({
...o1, // anything part of "o1" will be here as well!
baz: boolean, // add "baz" to "o1", forcing it to be a "boolean"
...allowAdditionalProperties(false), // no more additional properties here!
} as const)
When allowing extra properties, or extending objects, we might want to validate
the non-existance of a specific property. We can do this setting a property
to never
.
import { allowAdditionalProperties, never, number, object, string } from 'justus'
const o1 = object({
foo: string, // any string
bar: number, // any number
})
const o2 = object({
...o1, // anything part of "o1" will be here as well!
bar: never, // remove "bar" from the properties inherited by "o1"
} as const)
const o3 = object({
...o1, // anything part of "o1" will be here as well!
...allowAdditionalProperties, // allow additional properties as "any"
baz: never, // even with additional properties, "baz" must not exist
} as const)
When attempting to validate a simple Record<string, ...anything...>
the
function objectOf
can come handy:
import { objectOf, number } from 'justus'
const o1 = objectOf(number)
// here "o1" will have the shape "Record<string, number>""
const o2 = objectOf({ test: number })
// here "o2" will have the shape "Record<string, { test: number }>"
Optional properties properties can also be declared in objects, arrays, ...:
import { object, arrayOf, optional, string, number, validate } from 'justus'
const o1 = object({
foo: string, // any string, but must be a string
bar: optional(number), // optional property as "number | undefined"
})
const r1 = validate(o1, something)
// here "r1" will have a shape like "{ foo: string, bar?: number | undefined }"
const o2 = arrayOf(optional(string)) // array members will be "string | undefined"
const r2 = validate(o2, something)
// here "r2" will have a shape like "(string | undefined)[]"
The optional validator can also be used to inject default values in case
the source object doesn't have one. To do so, we can simply use the second
parameter of our optional(...)
function:
import { object, optional, number, validate } from 'justus'
const o1 = object({
foo: optional(number, 123), // any number, default is 123
})
const r1 = validate(o1, {})
// here "r1" will be "{ foo: 123 }" (the default value)
const r2 = validate(o1, { foo: 321 })
// here "r2" will be "{ foo: 321 }" (overrides the default value)
Unions (either all or any) are defined using the allOf
or oneOf
functions.
To make sure all validations pass use allOf
:
import { allOf, number, object, string, validate } from 'justus'
const o1 = object({ foo: string })
const o2 = object({ bar: number })
const result = validate(allOf(o1, o2), something)
// result here will have the type of what's inferred by o1 _and_ o2
result.foo // <-- this is a "string"
result.bar // <-- this is a "number"
// be careful about never!
const result2 = validate(allOf(number, string), something)
// obviously "result2" will be of type "never" as "number" and "string" do not match!
More useful, to make sure all validations pass use oneOf
:
import { number, oneOf, string, validate } from 'justus'
const result = validate(oneOf(number, string), something)
result // <-- its type will be "number | string"
A number of extra validators are also included, but not exported by default in order to reduce the bundle size when Justus is bundled in client apps.
Those are:
arn
: Validate an ARN (Amazon Resource Name) as a stringimport { arn } from 'justus/extra/arn'
parseArn
: Validate an ARN (Amazon Resource Name) and parse its componentsimport { parseArn } from 'justus/extra/arn'
ean13
: Validate a standard EAN-13 barcode number (as a string)import { ean13 } from 'justus/extra/ean13'
url
: Validate a string URL and converts it into a properURL
objectimport { url } from 'justus/extra/url'
uuid
: Validate a string UUID plain and simpleimport { uuid } from 'justus/extra/uuid'
The validate(...)
function accepts (as a third parameter) some validation
options. Those are:
stripAdditionalProperties
: thevalidate(...)
function will ignore any object property that was not declared in the schema, and will strip them out of the returned object (rather than failing).stripForbiddenProperties
: thevalidate(...)
function will ignore any forbidden property that was declared in the schema (for more info see below), and will strip them out of the returned object (rather than failing).stripOptionalNulls
: thevalidate(...)
function will ignore optional properties withnull
values and strip them out of the returned object. This is convenient when validating/stripping results coming from a database table, where columns are declared as nullable.
The strip(...)
function is a convenience wrapper around validate(...)
implying stripAdditionalProperties
and stripOptionalNulls
. Therefore:
import { validate, strip } from 'justus'
const result1 = strip(schema, object)
// is equivalent to
const result2 = validate(schema, object, {
stripAdditionalProperties: true,
stripForbiddenProperties: false,
stripOptionalNulls: true,
})
This example is not complicated at all, but it outlines the simplicity of the syntax intended for JUSTUS.
Let's assume we have some time series data, but we can expect this in a couple of different flavors, either V1 or V2 with some subtle differences:
import { arrayOf, date, number, object, oneOf, string, tuple, validate } from 'justus'
// Our V1 time-series tuple is simply a timestamp followed by a numeric value
const entryv1 = tuple([ date, number ] as const)
// Our V1 response from the time series database declares the version and data
const responsev1 = object({
version: 1,
entries: arrayOf(entryv1),
} as const)
// Our V2 time-series tuple is a timestamp, followed by a number and zero or
// more strings indicating some remarks on the measurements
const entryv2 = tuple([ date, number, ...string ] as const)
// Response for V2 is the same as V1, with some extra stuff
const responsev2 = object({
version: 2,
entries: arrayOf(entryv2),
average: number, // this is extra!
} as const)
// Our combined response is either V1 or V2
const response = oneOf(responsev1, responsev2)
// GO! Validate!
const result = validate(response, {})
if (result.version === 1) {
result.version // the type here will be the literal number 1
result.entries.forEach((entry) => {
entry[0] // this will be a `Date` instance
entry[1] // this will be a "number"
// entry[2] // will generate a typescript error
})
// response.average // will generate a typescript error
} else {
result.version // the type here will be the literal number 2
result.entries.forEach((entry) => {
entry[0] // this will be a `Date` instance
entry[1] // this will be a "number"
entry[2] // this will be a "string"
entry[999] // this too will be a "string"
})
result.average // this will be a "number""
}
Sometimes it might be necessary to generate .d.ts
files for your schemas,
rather than relying on the type inference provided by JUSTUS.
For example, if you were to use JUSTUS on a server application to validate HTTP requests and responses, and wanted to have strong typing when interacting with it from a client, you might not necessarily want to have JUSTUS (and your schemas) as a client dependency.
So, assuming your schemas might look something like this:
import { number, object, string } from 'justus'
// this will be exported as a type
const uuid = string({ brand: 'uuid ' })
// this will be embedded in product below
const price = number({ brand: 'price' })
// object mapping two validators above
const product = object({
uuid,
price,
name: string({ minLength: 1 }),
})
We can generate the DTS for UUID
and Product
(we specifically not export
Product
in this example) using our dts-generator
as follows:
import { generateTypes } from 'justus/dts-generator'
// Note how we rename the exports to "UUID" and "Product" (casing, ...)
const dts = generateTypes({
UUID: uuid,
Product: product,
})
The resulting dts
will be a string
containing the DTS as follows:
export type UUID = string & {
__brand_uuid : never;
};
export type Product = {
uuid: UUID;
price: number & {
__brand_price: never;
};
name: string;
};