doubter
v5.1.0
Published
Runtime validation and transformation library.
Downloads
11,499
Maintainers
Readme
Runtime validation and transformation library.
- TypeScript first;
- Sync and async validation and transformation flows;
- Circular object references support;
- Collect all validation issues, or exit early;
- Runtime type introspection;
- Human-oriented type coercion;
- High performance and low memory consumption;
- Zero dependencies;
- Pluggable architecture;
- Tree-shakable: 3 — 12 kB gzipped depending on what features you use;
- Check out the Cookbook for real-life examples!
npm install --save-prod doubter
[!IMPORTANT]
Docs on thenext
branch describe the canary releasedoubter@next
. Navigate to thelatest
branch for docs that describe the latest stable release.
🚀 Features
- Introduction
- Validation errors
- Operations
- Conversions
- Early return
- Annotations and metadata
- Parsing context
- Shape piping
- Replace, allow, and deny a value
- Optional and non-optional
- Nullable and nullish
- Exclude a shape
- Deep partial
- Fallback value
- Branded types
- Type coercion
- Introspection
- Localization
- Plugins
- Advanced shapes
🎯 Data types
Strings
string
Symbols
symbol
Objects
object
record
instanceOf
Dates
date
Promises
promise
Shape composition
union
or
intersection
and
not
🍪 Cookbook
- Type-safe URL query params
- Type-safe environment variables
- Type-safe CLI arguments
- Type-safe
localStorage
- Rename object keys
- Conditionally applied shapes
Introduction
Let's create a simple shape of a user:
import * as d from 'doubter';
const userShape = d.object({
name: d.string(),
age: d.number()
});
// ⮕ Shape<{ name: string, age: number }>
This is the shape of an object with two required properties "name" and "age". Shapes are the core concept in Doubter, they are validation and transformation pipelines that have an input and an output.
Apply the shape to an input value with the parse
method:
userShape.parse({
name: 'John Belushi',
age: 30
});
// ⮕ { name: 'John Belushi', age: 30 }
If the provided value is valid, then it is returned as is. If an incorrect value is provided, then a validation error is thrown:
userShape.parse({
name: 'Peter Parker',
age: 'seventeen'
});
// ❌ ValidationError: type.number at /age: Must be a number
Currently, the only constraint applied to the "age" property value is that it must be a number. Let's modify the shape to check that age is an integer and that user is an adult:
const userShape = d.object({
name: d.string(),
- age: d.number()
+ age: d.number().int().between(18, 100)
});
Here we added two operations to the number shape. Operations can check, refine, and alter input values. There are lots of operations available through plugins, and you can easily add your own operation when you need a custom logic.
Now shape would not only check that the "age" is a number, but also assert that it is an integer between 18 and 100:
userShape.parse({
name: 'Peter Parker',
age: 16
});
// ❌ ValidationError: number.gte at /age: Must be greater than or equal to 18
If you are using TypeScript, you can infer the type of the value that the shape describes:
type User = d.Input<typeof userShape>;
const user: User = {
name: 'Dan Aykroyd',
age: 27
};
Read more about static type inference and runtime type introspection.
Async shapes
Most of the shapes are synchronous, but they may become asynchronous when one of the below is used:
- Async operations;
- Async conversions;
d.promise
that constrains the fulfilled value;- Custom async shapes.
Let's have a look at a shape that synchronously checks that an input value is a string:
const shape1 = d.string();
// ⮕ Shape<string>
shape1.isAsync // ⮕ false
If we add an async operation to the string shape, it would become asynchronous:
const shape2 = d.string().checkAsync(
value => doAsyncCheck(value)
);
// ⮕ Shape<string>
shape2.isAsync // ⮕ true
The shape that checks that the input value is a Promise
instance is synchronous, because it doesn't have to wait for
the input promise to be fulfilled before ensuring that input has a proper type:
const shape3 = d.promise();
// ⮕ Shape<Promise<any>>
shape3.isAsync // ⮕ false
But if you want to check that a promise is fulfilled with a number, here when the shape becomes asynchronous:
const shape4 = d.promise(d.number());
// ⮕ Shape<Promise<number>>
shape4.isAsync // ⮕ true
Asynchronous shapes don't support synchronous parsing, and would throw an error if it is used:
shape4.parse(Promise.resolve(42));
// ❌ Error: Shape is async
shape4.parseAsync(Promise.resolve(42));
// ⮕ Promise { 42 }
On the other hand, synchronous shapes support asynchronous parsing:
d.string().parseAsync('Mars');
// ⮕ Promise { 'Mars' }
The shape that depends on an asynchronous shape, also becomes asynchronous:
const userShape = d.object({
avatar: d.promise(d.instanceOf(Blob))
});
// ⮕ Shape<{ avatar: Promise<Blob> }>
userShape.isAsync // ⮕ true
Parsing and trying
All shapes can parse input values and there are several methods for that purpose. Consider a number shape:
const shape1 = d.number();
// ⮕ Shape<number>
The parse
method takes an input value and
returns an output value, or throws a validation error if parsing fails:
shape.parse(42);
// ⮕ 42
shape.parse('Mars');
// ❌ ValidationError: type.number at /: Must be a number
It isn't always convenient to write a try-catch blocks to handle validation errors. Use the
try
method in such cases:
shape.try(42);
// ⮕ { ok: true, value: 42 }
shape.try('Mars');
// ⮕ { ok: false, issues: [ … ] }
Read more about issues in Validation errors section.
Sometimes you don't care about validation errors, and want a default value to be returned if things go south. Use the
parseOrDefault
method for that:
shape.parseOrDefault(42);
// ⮕ 42
shape.parseOrDefault('Mars');
// ⮕ undefined
shape.parseOrDefault('Pluto', 5.3361);
// ⮕ 5.3361
If you need a fallback value for a nested shape consider using the catch
method.
For asynchronous shapes there's an alternative for each of those methods:
parseAsync
,
tryAsync
, and
parseOrDefaultAsync
.
Methods listed in this section can be safely detached from the shape instance:
const { parseOrDefault } = d.string();
parseOrDefault('Jill');
// ⮕ 'Jill'
parseOrDefault(42);
// ⮕ undefined
All parsing methods accept options argument.
d.number().parse('42', { earlyReturn: true });
// ⮕ 42
Following options are available:
If true
then parsing is aborted after the first issue is encountered. Refer to Early return section
for more details.
The custom context that can be accessed from custom check callbacks, refinement predicates, alteration callbacks, converters, and fallback functions. Refer to Parsing context section for more details.
An object that maps an issue code to a default message. Refer to Override default messages section for more details.
Static type inference
[!IMPORTANT]
Static type inference feature requires TypeScript 4.1 + with enabledstrictNullChecks
.
Since shapes can transform values, they can have different input and output types. For example, this string shape has the same input an output:
const shape1 = d.string();
// ⮕ Shape<string>
shape1.parse('Pluto');
// ⮕ 'Pluto'
shape1.parse(undefined);
// ❌ ValidationError: type.string at /: Must be a string
Let's derive a new shape that would replace undefined
input values with a default value
"Mars":
const shape2 = shape1.optional('Mars');
// ⮕ Shape<string | undefined, string>
shape2.parse('Pluto');
// ⮕ 'Pluto'
// 🟡 Replaces undefined with the default value
shape2.parse(undefined);
// ⮕ 'Mars'
Infer the input and output types of shape2
:
type Shape2Input = d.Input<typeof shape2>;
// ⮕ string | undefined
type Shape2Output = d.Output<typeof shape2>;
// ⮕ string
Besides static type inference, you can check at runtime what input types and literal values does the shape accept using shape introspection:
shape2.inputs;
// ⮕ [Type.STRING, undefined]
shape2.accepts(d.Type.STRING);
// ⮕ true
shape2.accepts('Mars');
// ⮕ true
shape2.accepts(42);
// ⮕ false
Validation errors
Validation errors which are thrown by parsing methods, and
Err
objects returned by
try
and tryAsync
methods have the issues
property which holds an array of validation issues:
const shape = d.object({ age: d.number() });
// ⮕ Shape<{ age: number }>
const result = shape.try({ age: 'seventeen' });
The result
contains the Err
object
with the array of issues:
{
ok: false,
issues: [
{
code: 'type.number',
path: ['age'],
input: 'seventeen',
message: 'Must be a number',
param: undefined,
meta: undefined
}
]
}
The code of the validation issue. In the example above, "type"
code refers to a failed number type check. While shapes
check input value type and raise type issues, there also various operations that also may raise
issues with unique codes, see the table below.
You can add a custom operation to any shape and return an issue with your custom code.
The object path as an array of keys, or undefined
if there's no path. Keys can be strings, numbers (for example, array
indices), symbols, and any other values since they can be Map
keys, see d.map
.
The input value that caused a validation issue. Note that if the shape applies type coercion,
conversions, or if there are operations that transform the value, then input
may contain an already
transformed value.
The human-readable issue message. Refer to Localization section for more details.
The parameter value associated with the issue. For built-in checks, the parameter value depends on code
, see the table
below.
The optional metadata associated with the issue. Refer to Annotations and metadata section for more details.
| Code | Caused by | Param |
|:--------------------|:----------------------------------------------------|:------------------------------------------------------|
| any.deny
| shape.deny(x)
| The denied value x
|
| any.exclude
| shape.exclude(…)
| The excluded shape |
| any.refine
| shape.refine(…)
| The predicate callback |
| array.includes
| d.array().includes(x)
| The included value x
|
| array.min
| d.array().min(n)
| The minimum array length n
|
| array.max
| d.array().max(n)
| The maximum array length n
|
| bigint.min
| d.bigint().min(n)
| The minimum value n
|
| bigint.max
| d.bigint().max(n)
| The maximum value n
|
| date.min
| d.date().min(n)
| The minimum value n
|
| date.max
| d.date().max(n)
| The maximum value n
|
| number.finite
| d.number().finite()
| — |
| number.int
| d.number().int()
| — |
| number.gt
| d.number().gte(x)
| The minimum value x
|
| number.lt
| d.number().lte(x)
| The maximum value x
|
| number.gte
| d.number().gt(x)
| The exclusive minimum value x
|
| number.lte
| d.number().lt(x)
| The exclusive maximum value x
|
| number.multipleOf
| d.number().multipleOf(x)
| The divisor x
|
| object.allKeys
| d.object().allKeys(keys)
| The keys
array |
| object.notAllKeys
| d.object().notAllKeys(keys)
| The keys
array |
| object.orKeys
| d.object().orKeys(keys)
| The keys
array |
| object.xorKeys
| d.object().xorKeys(keys)
| The keys
array |
| object.oxorKeys
| d.object().oxorKeys(keys)
| The keys
array |
| object.exact
| d.object().exact()
| The array of unknown keys |
| object.plain
| d.object().plain()
| — |
| set.min
| d.set().min(n)
| The minimum Set
size n
|
| set.max
| d.set().max(n)
| The maximum Set
size n
|
| string.nonBlank
| d.string().nonBlank()
| — |
| string.min
| d.string().min(n)
| The minimum string length n
|
| string.max
| d.string().max(n)
| The maximum string length n
|
| string.regex
| d.string().regex(re)
| The regular expression re
|
| string.includes
| d.string().includes(x)
| The included string x
|
| string.startsWith
| d.string().startsWith(x)
| The substring x
|
| string.endsWith
| d.string().endsWith(x)
| The substring x
|
| type.array
| d.array()
| — |
| type.bigint
| d.bigint()
| — |
| type.boolean
| d.boolean()
| — |
| type.const
| d.const(x)
| The expected constant value x
|
| type.date
| d.date()
| — |
| type.enum
| d.enum(…)
| The array of unique value |
| type.function
| d.function()
| — |
| type.instanceOf
| d.instanceOf(Class)
| The class constructor Class
|
| type.intersection
| d.and(…)
| — |
| type.map
| d.map()
| — |
| type.never
| d.never()
| — |
| type.number
| d.number()
| — |
| type.object
| d.object()
| — |
| type.promise
| d.promise()
| — |
| type.tuple
| d.tuple(…)
| The expected tuple length |
| type.set
| d.set()
| — |
| type.string
| d.string()
| — |
| type.symbol
| d.symbol()
| — |
| type.union
| d.or(…)
| Issues raised by a union |
Operations
[!IMPORTANT]
While operations are a powerful tool, most of the time you don't need to add operations directly. Instead, you can use the higher-level API: checks, refinements, and alterations.
Operations can check and transform the shape output value. Let's create a shape with an operation that trims an input string:
const shape1 = d.string().addOperation(value => {
return { ok: true, value: value.trim() };
});
// ⮕ StringShape
shape1.parse(' Space ');
// ⮕ 'Space'
Operations added via addOperation
must return a Result
:
null
if the value is valid and unchanged;- an
Ok
object (as in example above) if the value was transformed; - an array of
Issue
objects if the operation has failed.
Multiple operations can be added to shape, and they are executed in the same order they were added. To access all
operations that were added use the
operations
property.
In contrast to conversions and pipes, operations don't change the base shape. So you can mix them with other operations that belong to the prototype of the base shape:
const shape2 = d
.string()
.addOperation(value => {
return { ok: true, value: value.trim() };
})
// 🟡 d.StringShape.prototype.min
.min(6);
shape2.parse(' Neptune ');
// ⮕ 'Neptune'
shape2.parse(' Moon ');
// ❌ ValidationError: string.min at /: Must have the minimum length of 6
Operations can be parameterized. This is particularly useful if you want to reuse the operation multiple times.
const checkRegex: d.OperationCallback = (value, param) => {
if (param.test(value)) {
return null;
}
return [{ message: 'Must match ' + param }];
};
// 🟡 Pass a param when operation is added
const shape3 = d.string().addOperation(checkRegex, { param: /a/ });
// ⮕ StringShape
shape3.parse('Mars');
// ⮕ 'Mars'
shape3.parse('Venus');
// ❌ ValidationError: unknown at /: Must match /a/
Operations have access to parsing options, so you can provide a custom context to change the operation behaviour:
const shape4 = d.string().addOperation((value, param, options) => {
return {
ok: true,
value: value.substring(options.context.substringStart)
};
});
// ⮕ StringShape
shape4.parse(
'Hello, Bill',
{
// 🟡 Provide the context during parsing
context: { substringStart: 7 }
}
);
// ⮕ 'Bill'
Operations can throw a ValidationError
to notify Doubter that parsing issues occurred. While this has the same effect as returning an array of issues, it is
recommended to throw a ValidationError
as the last resort since catching errors has a high performance penalty.
const shape5 = d.number().addOperation(value => {
if (value < 32) {
throw new ValidationError([{ code: 'too_small' }]);
}
return null;
});
shape5.try(16);
// ⮕ { ok: false, issues: [{ code: 'too_small' }] }
Tolerance for issues
Operations are executed only if the base shape type requirements are satisfied:
const shape = d.string().addOperation(value => {
return { ok: true, value: value.trim() };
});
// 🟡 Operation isn't executed because 42 isn't a string
shape.parse(42);
// ❌ ValidationError: type.string at /: Must be a string
For composite shapes, operations may become non-type-safe. Let's consider an object shape with an operation:
const checkUser: d.OpeationCallback = user => {
if (user.age < user.yearsOfExperience) {
return [{ code: 'invalid_age' }];
}
return null;
};
const userShape = d
.object({
age: d.number(),
yearsOfExperience: d.number()
})
.addOperation(checkUser);
// ⮕ Shape<{ age: number, yearsOfExperience: number }>
The checkUser
operation is guaranteed to receive an object, but its properties aren't guaranteed to have correct
types.
Use tolerance
operation
option to change how the operation behaves in case there are issues caused by the shape it is added to:
If the shape or preceding operations have raised issues, then the operation is skipped but consequent operations are still applied.
If the shape or preceding operations have raised issues, then the operation is skipped and consequent operations aren't applied. Also, if this operation itself raises issues then consequent operations aren't applied.
The operation is applied regardless of previously raised issues. This is the default behavior.
So to make checkUser
operation type-safe, we can use "skip" or "abort".
const userShape = d
.object({
age: d.number(),
yearsOfExperience: d.number()
})
- .addOperation(checkUser);
+ .addOperation(checkUser, { tolerance: 'abort' });
Some shapes cannot guarantee that the input value is of the required type. For example, if any of the underlying shapes in an intersection shape have raised issues, an intersection shape itself cannot guarantee that its operations would receive the value of the expected type, so it doesn't apply any operations if there are issues.
These shapes never apply operations if an underlying shape has raised an issue:
Async operations
Operations callbacks can be asynchronous. They have the same set of arguments as synchronous alternative, by must return a promise. Consequent operations after the asynchronous operation would wait for its result:
const shape = d
.string()
.addAsyncOperation(async value => {
if (await doAsyncCheck(value)) {
return null;
}
return [{ code: 'kaputs' }];
});
shape.isAsync;
// ⮕ true
shape.parseAsync('Hello');
Adding an async operation to the shape, makes shape itself async, so use
parseAsync
,
tryAsync
, or
parseOrDefaultAsync
.
Checks
Checks are the most common operations that allow constraining the input value beyond type assertions. For example, if you want to constrain a numeric input to be greater than or equal to 5:
const shape = d.number().check(value => {
if (value < 5) {
// 🟡 Return an issue, or an array of issues
return { code: 'kaputs' };
}
});
// ⮕ NumberShape
shape.parse(10);
// ⮕ 10
shape.parse(3);
// ❌ ValidationError: kaputs at /
A check callback receives the shape output value and must return an issue or an array of issues if the value is invalid.
If the value is valid, a check callback must return null
, undefined
, or an empty array.
Add asynchronous checks using
checkAsync
. This method has the same
semantics as check
but returns a promise
and makes the shape asynchronous.
[!NOTE]
You can parameterize checks and set tolerance for issues the same way as any other operation.
Most shapes have a set of built-in checks. The check we've just implemented above is called
gte
(greater than equals):
d.number().gte(5);
Add as many checks as you need to the shape. You can mix built-in checks with any other custom operations, they are executed in the same order they were added.
d.string().max(4).regex(/a/).try('Pluto');
In the example above, an Err
object is
returned:
{
ok: false,
issues: [
{
code: 'string.max',
path: [],
input: 'Pluto',
message: 'Must have the maximum length of 4',
param: 4,
meta: undefined
},
{
code: 'string.regex',
path: [],
input: 'Pluto',
message: 'Must match the pattern /a/',
param: /a/,
meta: undefined
}
]
}
[!NOTE]
You can find the list of issue codes and corresponding param values in Validation errors section.
Refinements
Refinements are operations that use a predicate callback to validate an input. For example, the shape below would raise an issue if the input string is less than six characters long.
const shape1 = d.string().refine(value => value.length > 5);
// ⮕ Shape<string>
shape1.parse('Uranus');
// ⮕ 'Uranus'
shape1.parse('Mars');
// ❌ ValidationError: any.refine at /: Must conform the predicate
Add asynchronous refinements using
refineAsync
. This method has the
same semantics as refine
but returns a
promise and makes the shape asynchronous.
Use refinements to narrow the output type of the shape:
function isMarsOrPluto(value: string): value is 'Mars' | 'Pluto' {
return value === 'Mars' || value === 'Pluto';
}
d.string().refine(isMarsOrPluto)
// ⮕ Shape<string, 'Mars' | 'Pluto'>
By default, refine
raises issues which have the "any.refine"
code. You can provide a custom
code:
const shape2 = d.string().refine(
isMarsOrPluto,
{
code: 'illegal_planet',
message: 'Must be Mars or Pluto'
}
);
shape2.parse('Venus');
// ❌ ValidationError: illegal_planet at /: Must be Mars or Pluto
[!NOTE]
You can parameterize refinements and set tolerance for issues the same way as any other operation.
Alterations
Alterations are operations that synchronously transform the shape output value without changing its type. For example, let's consider a string shape that trims the value and then checks that it has at least 3 characters:
d.string()
.alter(value => value.trim())
.min(3);
// ⮕ StringShape
Add asynchronous alterations using
alterAsync
. This method has the
same semantics as alter
but returns a
promise and makes the shape asynchronous.
Use any transformation library in conjunction with alternations:
d.number().alter(Math.abs).alter(Math.pow, { param: 3 });
Alteration callbacks must return the value of the same type, so consequent operations are type-safe. If you want to convert the shape output value to another type, consider using conversions.
[!NOTE]
You can parameterize alterations and set tolerance for issues the same way as any other operation.
Conversions
Conversions are close relatives of alterations that also transform shape output value. The main difference from alterations is that conversions can change the shape output type. Let's consider a shape that takes a string as an input and converts it to a number:
const shape = d.string().convert(parseFloat);
// ⮕ Shape<string, number>
This shape ensures that the input value is a string and passes it to a converter callback:
shape.parse('42');
// ⮕ 42
shape.parse('seventeen');
// ⮕ NaN
Throw a ValidationError
inside the callback to notify parser that the conversion cannot be successfully completed:
function toNumber(input: string): number {
const output = parseFloat(input);
if (isNaN(output)) {
throw new d.ValidationError([{ code: 'nan' }]);
}
return output;
}
const shape = d.string().convert(toNumber);
shape.parse('42');
// ⮕ 42
shape.parse('seventeen');
// ❌ ValidationError: nan at /
Async conversions
Let's consider a synchronous conversion:
const syncShape1 = d.string().convert(
value => 'Hello, ' + value
);
// ⮕ Shape<string>
syncShape1.isAsync // ⮕ false
syncShape1.parse('Jill');
// ⮕ 'Hello, Jill'
The converter callback receives and returns a string and so does syncShape1
.
Now lets return a promise from the converter callback:
const syncShape2 = d.string().convert(
value => Promise.resolve('Hello, ' + value)
);
// ⮕ Shape<string, Promise<string>>
syncShape2.isAsync // ⮕ false
syncShape2.parse('Jill');
// ⮕ Promise<string>
Notice that syncShape2
is asymmetric: it expects a string input and converts it to a Promise<string>
. syncShape2
is still synchronous, since the converter callback synchronously wraps a value in a promise.
Now let's create an asynchronous shape using the async conversion:
const asyncShape1 = d.string().convertAsync(
value => Promise.resolve('Hello, ' + value)
);
// ⮕ Shape<string>
// 🟡 Notice that the shape is async
asyncShape1.isAsync // ⮕ true
await asyncShape1.parseAsync('Jill');
// ⮕ Promise { 'Hello, Jill' }
Notice that asyncShape1
converts the input string value to output string but the conversion itself is asynchronous.
A shape is asynchronous if it uses asynchronous conversions. Here's an asynchronous object shape:
const asyncShape2 = d.object({
foo: d.string().convertAsync(
value => Promise.resolve(value)
)
});
// ⮕ Shape<{ foo: string }>
asyncShape2.isAsync // ⮕ true
Refer to Async shapes section for more details on when shapes can become asynchronous.
Early return
By default, Doubter collects all issues during parsing. In some cases, you may want to halt parsing and raise a
validation error as soon as the first issue was encountered. To do this, pass the
earlyReturn
option to the parsing methods.
d.string()
.max(4)
.regex(/a/)
.try('Pluto', { earlyReturn: true });
This would return the Err
object with
only one issue:
{
ok: false,
issues: [
{
code: 'string.max',
path: undefined,
input: 'Pluto',
message: 'Must have the maximum length of 4',
param: 4,
meta: undefined
}
]
}
Annotations and metadata
Shapes and issues can be enriched with additional metadata.
Add an annotation to a shape:
const shape = d.string().annotate({ description: 'Username' });
shape.annotations;
// ⮕ { description: 'Username' }
annotate
returns the clone of the
shape with updated annotations. Annotations are merged when you add them:
shape.annotate({ foo: 'bar' }).annotations;
// ⮕ { description: 'Username', foo: 'bar' }
Validation issues have a
meta
property that you can use
to store an arbitrary data.
You can pass the meta
option to any built-in check and its value is assigned to the meta
property of the raised validation issue.
const shape = d.number().gt(5, { meta: 'Useful data' });
// ⮕ Shape<number>
const result = shape.try(2);
// ⮕ { ok: false, issues: … }
if (!result.ok) {
result.issues[0].meta // ⮕ 'Useful data'
}
This comes handy if you want to enhance an issue with an additional data that can be used later during issues processing. For example, during localization.
Parsing context
Inside operation callbacks, check callbacks, refinement predicates,
alteration callbacks, converters, fallback functions, and
message callbacks you can access options passed to the parser. The
context
option may
store an arbitrary data, which is undefined
by default.
For example, here's how you can use context to convert numbers to formatted strings:
const shape = d.number().convert(
(value, options) => new Intl.NumberFormat(options.context.locale).format(value)
);
// ⮕ Shape<number, string>
shape.parse(
1000,
{
// 🟡 Pass a context
context: { locale: 'en-US' }
}
);
// ⮕ '1,000'
Shape piping
With shape piping you to can pass the shape output to another shape.
d.string()
.convert(parseFloat)
.to(d.number().lt(5).gt(10));
// ⮕ Shape<string, number>
For example, you can validate that an input value is an instance of a class and then validate its
properties using object
:
class Planet {
constructor(readonly name: string) {}
}
const shape = d.instanceOf(Planet).to(
d.object({
name: d.string().min(4)
})
);
shape.parse({ name: 'Pluto' });
// ❌ ValidationError: type.instanceOf at /: Must be a class instance
shape.parse(new Planet('X'));
// ❌ ValidationError: string.min at /name: Must have the minimum length of 4
shape.parse(new Planet('Mars'));
// ⮕ Planet { name: 'Mars' }
Replace, allow, and deny a value
All shapes support replace
,
allow
, and
deny
methods that change how
separate literal values are processed.
Replace a value
You can replace an input value with an output value:
const shape1 = d.enum(['Mars', 'Pluto']).replace('Pluto', 'Jupiter');
// ⮕ Shape<'Mars' | 'Pluto', 'Mars' | 'Jupiter'>
shape1.parse('Mars');
// ⮕ 'Mars'
shape1.parse('Pluto');
// ⮕ 'Jupiter'
With replace
you can extend possible input values:
d.const('Venus').replace('Mars', 'Uranus');
// ⮕ Shape<'Venus' | 'Mars', 'Venus' | 'Uranus'>
This would also work with non-literal input types:
d.number().replace(0, 'zero');
// ⮕ Shape<number, number | 'zero'>
replace
narrows its arguments to literal type but in TypeScript type system not all values have a separate literal
type. For example, there's no literal type for NaN
and Infinity
values. In such cases replace
doesn't exclude the
replaced value type from the output type:
d.enum([33, 42]).replace(NaN, 0);
// ⮕ Shape<number, 33 | 42 | 0>
Replaced values aren't processed by the underlying shape:
const shape2 = d.number().gte(3).replace(0, 'zero');
// ⮕ Shape<number | 'zero'>
shape2.parse(2);
// ❌ ValidationError: number.gte at /: Must be greater than 3
// 🟡 Notice that 0 doesn't satisfy the gte constraint
shape2.parse(0);
// ⮕ 'zero'
Allow a value
You can allow a value as both input and output:
d.const('Mars').allow('Pluto');
// ⮕ Shape<'Mars' | 'Pluto'>
allow
follows exactly the same semantics as replace
.
You can allow a value for a non-literal input types:
const shape = d.number().finite().allow(NaN);
// ⮕ Shape<number>
shape.parse(NaN);
// ⮕ NaN
shape.parse(Infinity);
// ❌ ValidationError: number.finite at /: Must be a finite number
Deny a value
Consider the enum shape:
const shape1 = d.enum(['Mars', 'Pluto', 'Jupiter']);
// ⮕ Shape<'Mars' | 'Pluto' | 'Jupiter'>
To remove a value from this enum you can use the
deny
method:
shape1.deny('Pluto');
// ⮕ Shape<'Mars' | 'Jupiter'>
Value denial works with any shape. For example, you can deny a specific number:
const shape2 = d.number().deny(42);
// ⮕ Shape<number>
shape2.parse(33);
// ⮕ 33
shape2.parse(42);
// ❌ ValidationError: any.deny at /: Must not be equal to 42
deny
prohibits value for both input and output:
const shape3 = d.number().convert(value => value * 2).deny(42);
// ⮕ Shape<number>
shape3.parse(21);
// ❌ ValidationError: any.deny at /: Must not be equal to 42
Optional and non-optional
Marking a shape as optional allows undefined
in both its input and output:
d.string().optional();
// ⮕ Shape<string | undefined>
You can provide a default value of any type, so it would be used as an output if input value is undefined
:
d.string().optional(42);
// ⮕ Shape<string | undefined, string | 42>
You can achieve the same behaviour using a union:
d.or([
d.string(),
d.undefined()
]);
// ⮕ Shape<string | undefined>
Or using allow
:
d.string().allow(undefined);
// ⮕ Shape<string | undefined>
You can mark any shape as non-optional which effectively denies undefined
values from both
input and output. For example, lets consider a union of an optional string and a number:
const shape1 = d.or([
d.string().optional(),
d.number()
]);
// ⮕ Shape<string | undefined | number>
shape1.parse(undefined);
// ⮕ undefined
const shape2 = shape1.nonOptional();
// ⮕ Shape<string | number>
shape2.parse(undefined);
// ❌ ValidationError: any.deny at /: Must not be equal to undefined
Nullable and nullish
Marking a shape as nullable allows null
for both input and output:
d.string().nullable();
// ⮕ Shape<string | null>
You can provide a default value, so it would be used as an output if input value is null
:
d.string().nullable(42);
// ⮕ Shape<string | null, string | 42>
To allow both null
and undefined
values use nullish
:
d.string().nullish();
// ⮕ Shape<string | null | undefined>
nullish
also supports the default value:
d.string().nullish(8080);
// ⮕ Shape<string | null | undefined, string | 8080>
Exclude a shape
Shape exclusions work the same way as Exclude
helper type in TypeScript. When an exclusion is applied, the output
value returned by the underlying shape must not conform the excluded shape.
const shape = d.enum(['Mars', 'Venus', 'Pluto']).exclude(d.const('Pluto'));
// ⮕ Shape<'Mars' | 'Venus' | 'Pluto', 'Mars' | 'Venus'>
shape.parse('Mars');
// ⮕ 'Mars'
shape.parse('Pluto');
// ❌ ValidationError: any.exclude at /: Must not conform the excluded shape
Exclusions work with any shape combinations:
d.or([d.number(), d.string()]).exclude(d.string());
// ⮕ Shape<number | string, number>
Sometimes you need an exclusion at runtime, but don't need it on the type level. For example, let's define a shape that allows any number except the [3, 5] range:
// 🟡 Note that the shape output is inferred as never
d.number().exclude(d.number().min(3).max(5));
// ⮕ Shape<number, never>
Since the excluded shape constrains the number
type, the output type is inferred as never
. While the excluded shape
only restricts a limited range of numbers, there's no way to express this in TypeScript. So here's the workaround:
d.number().not(d.number().min(3).max(5));
// ⮕ Shape<number>
not
works exactly like exclude
at runtime, but it doesn't perform the exclusion on the type level.
d.enum(['Bill', 'Jill']).not(d.const('Jill'));
// ⮕ Shape<'Bill', 'Jill'>
You can also use d.not
to negate an arbitrary shape.
Deep partial
All object-like shapes (objects, arrays, maps, sets, promises, etc.) can be converted to a deep partial alternative
using deepPartial
method:
const shape1 = d.array(
d.object({
name: d.string(),
age: d.number()
})
);
// ⮕ Shape<{ name: string, age: number }[]>
shape1.deepPartial();
// ⮕ Shape<Array<{ name?: string, age?: number } | undefined>>
Unions, intersections and lazy shapes can also be converted to deep partial:
const shape2 = d
.or([
d.number(),
d.object({ name: d.string() })
])
.deepPartial()
// ⮕ Shape<number | { name?: string }>
shape2.parse(42);
// ⮕ 42
shape2.parse({ name: undefined });
// ⮕ { name: undefined }
shape2.parse({ name: 'Frodo' });
// ⮕ { name: 'Frodo' }
shape2.parse({ name: 8080 });
// ❌ ValidationError: type.string at /name: Must be a string
Deep partial isn't applied to converted shapes:
const shape2 = d
.object({
years: d.array(d.string())
.convert(years => years.map(parseFloat))
})
.deepPartial();
// ⮕ Shape<{ years?: string[] }, { years?: number[] }>
In the example above, array elements don't allow undefined
even after deepPartial
was applied, this happened because
array is converted during parsing.
[!NOTE]
You can also implement deep partial protocol in your custom shapes.
Fallback value
If issues were detected during parsing a shape can return a fallback value.
const shape1 = d.string().catch('Mars');
shape1.parse('Pluto');
// ⮕ 'Pluto'
shape1.parse(42);
// ⮕ 'Mars'
Pass a callback as a fallback value, it would be executed every time the catch clause is reached:
const shape2 = d.number().catch(Date.now);
shape2.parse(42);
// ⮕ 42
shape2.parse('Pluto');
// ⮕ 1671565311528
shape2.parse('Mars');
// ⮕ 1671565326707
Fallback functions receive an input value, an array of issues and parsing options (so you can access your custom context if needed).
d.string().catch((input, issues, options) => {
// Return a fallback value
});
A fallback function can throw a ValidationError
to indicate that a fallback value cannot be
produced. Issues from this error would be incorporated in the parsing result.
const shape3 = d.object({
name: d.string().catch(() => {
throw new d.ValidationError([{ code: 'kaputs' }]);
})
});
shape3.parse({ name: 47 });
// ❌ ValidationError: kaputs at /name
Branded types
In TypeScript, values are considered to be of equivalent type if they are structurally the same. For example, plain strings are assignable to one another:
function bookTicket(flightCode: string): void {
// Booking logic
}
// 🟡 No type errors, but "Bill" isn't a flight code
bookTicket('Bill');
In some cases, it can be desirable to simulate nominal typing inside TypeScript. For instance, you may wish to write a function that only accepts an input that has been validated by Doubter. This can be achieved with branded types:
const flightCodeShape = d.string().refine(isFlightCode).brand<'flightCode'>();
// ⮕ Shape<string, Branded<string, 'flightCode'>>
type FlightCode = d.Output<typeof flightCodeShape>;
// 🟡 Note that the argument type isn't a plain string
function bookTicket(flightCode: FlightCode): void {
// Booking logic
}
bookTicket(flightCodeShape.parse('BA2490'));
// Ok, valid flight code
bookTicket('Bill');
// ❌ Error: Expected BRAND to be flightCode
[!NOTE]
Branded types don't affect the runtime result ofparse
. It is a static-type-only construct.
Type coercion
Type coercion is the process of converting value from one type to another (such as a string to a number, an array to
a Set
, and so on).
When coercion is enabled, input values are implicitly converted to the required input type whenever possible. For example, you can coerce input values to a number type:
const shape = d.number().coerce();
// ⮕ NumberShape
shape.isCoercing // ⮕ true
shape.parse([new String('8080')]);
// ⮕ 8080
shape.parse(null);
// ⮕ 0
Coercion rules differ from JavaScript so the behavior is more predictable and human-like. With Doubter, you can coerce input to the following types:
Custom type coercion
If you want to implement a custom coercion, you can use catch
to handle invalid input values:
const yesNoShape = d.boolean().catch((value, issues) => {
if (value === 'yes') {
return true;
}
if (value === 'no') {
return false;
}
throw new ValidationError(issues);
});
yesNoShape.parse('yes');
// ⮕ true
d.array(yesNoShape).parse([true, 'no']);
// ⮕ [true, false]
yesNoShape.parse('true');
// ❌ ValidationError: type.boolean at /: Must be a boolean
Or you can use d.convert
to preprocess all input values:
const yesNoShape = d
.convert(value => {
if (value === 'yes') {
return true;
}
if (value === 'no') {
return false;
}
// Let the consequent shape handle this value
return value;
})
.to(d.boolean());
yesNoShape.parse('yes');
// ⮕ true
yesNoShape.parse('true');
// ❌ ValidationError: type.boolean at /: Must be a boolean
Introspection
Doubter provides various features to introspect your shapes at runtime. Let's start by accessing a shape input types
using the inputs
property:
const shape1 = d.or([d.string(), d.boolean()]);
// ⮕ Shape<string | boolean>
shape1.inputs;
// ⮕ [Type.STRING, Type.BOOLEAN]
inputs
array may contain literal values:
d.enum(['Mars', 42]).inputs;
// ⮕ ['Mars', 42]
Literal values are absorbed by their type in unions.
const shape2 = d.or([
d.enum(['Uranus', 1984]),
d.number()
]);
// ⮕ Shape<'Uranus' | number>
shape2.inputs;
// ⮕ ['Uranus', Type.NUMBER]
If inputs
is an empty array, it means that the shape doesn't accept any input values, and would always raise
validation issues.
const shape3 = d.and([d.number(), d.const('Mars')]);
// ⮕ Shape<never>
shape3.inputs;
// ⮕ []
To detect the type of the value use
Type.of
:
Type.of('Mars');
// ⮕ Type.STRING
Type.of(Type.NUMBER);
// ⮕ Type.NUMBER
Types returned from Type.of
are a superset of types returned from the typeof
operator.
Unknown value type
Type.UNKNOWN
type emerges when accepted inputs cannot be statically inferred. For example, if d.any
,
d.unknown
, or d.convert
are used:
const shape1 = d.convert(parseFloat);
// ⮕ Shape<any>
shape1.inputs;
// ⮕ [Type.UNKNOWN]
Type.UNKNOWN
behaves like TypeScript's unknown
.
It absorbs other types in unions:
const shape2 = d.or([d.string(), d.unknown()]);
// ⮕ Shape<unknown>
shape2.inputs;
// ⮕ [Type.UNKNOWN]
And it is erased in intersections:
const shape3 = d.and([d.string(), d.unknown()]);
// ⮕ Shape<string>
shape3.inputs;
// ⮕ [Type.STRING]
const shape4 = d.and([d.never(), d.unknown()]);
// ⮕ Shape<never>
shape4.inputs;
// ⮕ []
Check that an input is accepted
To check that the shape accepts a particular input type or value use the
accepts
method:
const shape1 = d.string();
// ⮕ Shape<string>
shape1.accepts(Type.STRING);
// ⮕ true
shape1.accepts('Venus');
// ⮕ true
Check that a value is accepted:
const shape2 = d.enum(['Mars', 'Venus']);
// ⮕ Shape<'Mars' | 'Venus'>
shape2.accepts('Mars');
// ⮕ true
shape2.accepts('Pluto');
// ⮕ false
// 🟡 Enum doesn't accept arbitrary strings
shape2.accepts(Type.STRING);
// ⮕ false
For example, you can check that the shape is optional by checking that it accepts
undefined
input value:
const shape3 = d.number().optional();
// ⮕ Shape<number | undefined>
shape3.accepts(1984);
// ⮕ true
shape3.accepts(undefined);
// ⮕ true
// 🟡 Note that null isn't accepted
shape3.accepts(null);
// ⮕ false
The fact that a shape accepts a particular input type or value, does not guarantee that it wouldn't raise a validation
issue. For example, consider the pipe from d.any
to d.string
:
const fuzzyShape = d.any().to(d.string());
// ⮕ Shape<any, string>
fuzzyShape
accepts Type.UNKNOWN
because it is based on d.any
:
fuzzyShape.inputs;
// ⮕ [Type.UNKNOWN]
Since fuzzyShape
accepts any values, an undefined
is also accepted:
fuzzyShape.accepts(undefined);
// ⮕ true
But parsing undefined
with fuzzyShape
would produce an error, since undefined
doesn't satisfy d.string
on the
right-hand side of the pipe:
fuzzyShape.parse(undefined);
// ❌ ValidationError: type.string at /: Must be a string
Nested shapes
Object, array, union ond other composite shapes provide access to their nested shapes:
const userShape = d.object({
name: d.string(),
age: d.number()
});
// ⮕ Shape<{ name: string, age: number }>
userShape.propShapes.name;
// ⮕ Shape<string>
const userOrNameShape = d.or([userShape, d.string()]);
// ⮕ Shape<{ name: string, age: number } | string>
userOrNameShape.shapes[0];
// ⮕ userShape
Shape.at
method derives a sub-shape at the
given key, and if there's no such key then null
is returned:
userShape.at('age');
// ⮕ Shape<number>
userShape.at('emotionalDamage');
// ⮕ null
This is especially useful with unions and intersections:
const shape = d.or([
d.object({
foo: d.string()
}),
d.object({
foo: d.number()
})
]);
shape.at('foo')
// ⮕ Shape<string | number>
shape.at('bar')
// ⮕ null
Localization
All shape factories and built-in checks support a custom issue messages:
d.string('Hey, string here').min(3, 'Too short');
Pass a function as a message, and
it would receive an issue that would be raised, and parsing options. You can assign
issue.message
or return a message. For example, when using with React you may return a JSX element:
const reactMessage: d.Message = (issue, options) => (
<span style={{ color: 'red' }}>
The minimum length is {issue.param}
</span>
);
d.number().min(5, reactMessage);
Semantics described above are applied to the
message
option as well:
d.string().length(3, { message: 'Invalid length' })
Override default messages
Default issue messages can be overridden by
messages
option:
import * as d from 'doubter';
d.string().parse(42, {
messages: {
'type.string': 'Yo, not a string!'
}
});
// ❌ ValidationError: type.string at /: Yo, not a string!
The full list of issue codes can be found in Validation errors section.
Plugins
By default, when you import Doubter, you also get all built-in plugins as well:
import * as d from 'doubter';
d.string().min(2); // ✅ min is defined
d.number().gte(3); // ✅ gte is defined
If you import doubter/core
, you would get only core set of shapes without any plugins:
import * as d from 'doubter/core';
d.string().min(2); // ❌ min is undefined
d.number().gte(3); // ❌ gte is undefined
You can cherry-pick plugins that you need:
import * as d from 'doubter/core';
import 'doubter/plugin/string-essentials';
d.string().min(2); // ✅ min is defined
d.number().gte(3); // ❌ gte is undefined
Built-in plugins
Bigint essentials
positive
negative
nonPositive
nonNegative
min
max
Date essentials
min
max
after
before
toISOString
toTimestamp
Number essentials
finite
int
positive
negative
nonPositive
nonNegative
between
gt
lt
gte
lte
min
max
multipleOf
safe
Object essentials
plain
allKeys
notAllKeys
orKeys
xorKeys
oxorKeys
String essentials
length
min
max
regex
includes
startsWith
endsWith
nonBlank
nonEmpty
trim
[toLowerCase
](https://sm