4.0.3 • Published 2 days ago

doubter v4.0.3

Weekly downloads
-
License
MIT
Repository
github
Last release
2 days ago

Runtime validation and transformation library.

🔥Try Doubter on CodeSandbox

Read the docs and install the latest stable release:

npm install --save-prod doubter

Or install the canary release:

npm install --save-prod doubter@next

Features

Performance

Data types

Cookbook

Basics

Let's create a simple shape of a user object:

import * as d from 'doubter';

const userShape = d.object({
  name: d.string(),
  age: d.int().gte(18).lt(100)
});
// ⮕ Shape<{ name: string, age: number }>

This shape can be used to validate a value:

userShape.parse({
  name: 'John Belushi',
  age: 30
});
// ⮕ { name: 'John Belushi', age: 30 }

If an incorrect value is provided, a validation error is thrown:

userShape.parse({
  name: 'Peter Parker',
  age: 17
});
// ❌ ValidationError: numberGreaterThanOrEqual at /age: Must be greater than or equal to 18

Infer the user type from the shape:

type User = d.Output<typeof userShape>;

const user: User = {
  name: 'Dan Aykroyd',
  age: 27
};

Parsing and trying

Each shape can parse input values and there are several methods for that purpose.

Methods listed in this section can be safely detached from the shape instance:

const { parseOrDefault } = d.string();

parseOrDefault('Jill');
// ⮕ 'Jill'

parseOrDefault(42);
// ⮕ undefined

Parsing methods accept options argument.

d.number().parse('42', { coerced: true });
// ⮕ 42

Following options are available:

If true then Doubter collects all issues during parsing, otherwise parsing is aborted after the first issue is encountered. Refer to Verbose mode section for more details.

If true then all shapes that support type coercion would try to coerce an input to a required type. Refer to Type coercion section for more details.

The custom context that can be accessed from custom check callbacks, refinement predicates, transformers, and fallback functions. Refer to Parsing context section for more details.

This option is only available for parse and parseAsync methods. It configures a ValidationError message. If a callback is provided it receives issues and an input value, and must return a string message. If a string is provided, it is used as is. You can also configure global issue formatter that is used by ValidationError, refer to Global error message formatter section for more details.

parse

You're already familiar with parse that takes an input value and returns an output value, or throws a validation error if parsing fails:

const shape = d.number();
// ⮕ Shape<number>

shape.parse(42);
// ⮕ 42

shape.parse('Mars');
// ❌ ValidationError: type at /: Must be a number

Use parseAsync with async shapes. It has the same semantics and returns a promise.

parseOrDefault

Sometimes you don't care about validation errors, and want a default value to be returned if things go south:

const shape = d.number();
// ⮕ Shape<number>

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 catch.

Use parseOrDefaultAsync with async shapes. It has the same semantics and returns a promise.

try

It isn't always convenient to write a try-catch blocks to handle validation errors. Use try method in such cases:

const shape = d.number();
// ⮕ Shape<number>

shape.try(42);
// ⮕ { ok: true, value: 42 }

shape.try('Mars');
// ⮕ { ok: false, issues: [{ code: 'type', … }] }

Use tryAsync with async shapes. It has the same semantics and returns a promise.

Sync and async shapes

Shapes are validation and transformation pipelines that have an input and an output. Here's a shape that restricts an input to a string and produces a string as an output:

d.string();
// ⮕ Shape<string>

Shapes can have different input and output types. For example, the shape below allows strings and replaces undefined input values with a default value "Mars":

const shape = d.string().optional('Mars');
// ⮕ Shape<string | undefined, string>

shape.parse('Pluto');
// ⮕ 'Pluto'

shape.parse(undefined);
// ⮕ 'Mars'

Infer the input and output types of the shape:

type MyInput = d.Input<typeof shape>;
// ⮕ string | undefined

type MyOutput = d.Output<typeof shape>;
// ⮕ string

You can get input types and literal values that the shape accepts using shape introspection:

shape.inputs;
// ⮕ [Type.STRING, undefined]

Async shapes

What can make a shape asynchronous:

Here's a shape of a promise that is expected to be fulfilled with a number:

const shape = d.promise(d.number());
// ⮕ Shape<Promise<number>>

You can check that the shape is async:

shape.isAsync // ⮕ true

Async shapes don't support synchronous parse method, and would throw an error if it is called:

shape.parse(Promise.resolve(42));
// ❌ Error: Shape is async

Use parseAsync with async shapes instead:

shape.parseAsync(Promise.resolve(42));
// ⮕ Promise<42>

Any shape that relies on an async shape becomes async as well:

const userShape = d.object({
  name: d.promise(d.string())
});
// ⮕ Shape<{ name: Promise<string> }>

userShape.isAsync // ⮕ true

Validation errors

Validation errors which are thrown by parse* methods, and Err objects returned by try* 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',
      path: ['age'],
      input: 'seventeen',
      message: 'Must be a number',
      param: 'number',
      meta: undefied
    }
  ]
}

The code of the validation issue. Shapes provide various checks and each check has a unique code. In the example above, type code refers to a failed number type check. See the table of known codes below. You can add a custom check 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.

The input value that caused a validation issue. Note that if coercion is enabled this contains a coerced 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 Metadata section for more details.

CodeCaused byParam
arrayMinLengthd.array().min(n)The minimum array length n
arrayMaxLengthd.array().max(n)The maximum array length n
constd.const(x)The expected constant value x
deniedshape.deny(x)The denied value x
enumd.enum([x, y, z])The array of unique values[x, y, z]
excludedshape.exclude(…)The excluded shape
instanced.instanceOf(Class)The class constructor Class
intersectiond.and(…)
predicateshape.refine(…)The predicate callback
neverd.never()
numberIntegerd.integer()
numberFinited.finite()
numberGreaterThand.number().gt(x)The exclusive minimum value x
numberGreaterThanOrEquald.number().gte(x)The minimum value x
numberLessThand.number().lt(x)The exclusive maximum value x
numberLessThanOrEquald.number().lte(x)The maximum value x
numberMultipleOfd.number().multipleOf(x)The divisor x
setMinSized.set().min(n)The minimum Set size n
setMaxSized.set().max(n)The maximum Set size n
stringMinLengthd.string().min(n)The minimum string length n
stringMaxLengthd.string().max(n)The maximum string length n
stringRegexd.string().regex(re)The regular expression re
typeAll shapesThe expected input value type
tupled.tuple([…])The expected tuple length
uniond.or(…)Issues raised by a union
unknownKeysd.object().exact()The array of unknown keys

Global error message formatter

Be default, ValidationError uses JSON.stringify to produce an error message. While you can provide a custom error message by passing errorMessage option to parse and parseAsync, you also can configure the global formatter.

d.ValidationError.formatIssues = issues => {
  // Return a human-readable error message that describes issues
  return 'Something went wrong';
};

new d.ValidationError([]).message;
// ⮕ 'Something went wrong'

new d.ValidationError([], 'Kaputs').message;
// ⮕ 'Kaputs'

formatIssues is called whenever a message constructor argument is omitted.

Checks

Checks allow constraining the input value beyond type assertions. For example, if you want to constrain a numeric input to be greater than 5:

const shape = d.number().check(value => {
  if (value <= 5) {
    // 🟡 Return an issue, or an array of issues
    return { code: 'kaputs' };
  }
});
// ⮕ Shape<number>

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.

NoteCheck callbacks 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.

If value is valid, a check callback must return null, undefined, or an empty array.

Most shapes have a set of built-in checks. The check we've just implemented above is called gt (greater than):

d.number().gt(5);

Add as many checks as you need to the shape. You can mix custom and built-in checks, 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: 'stringMaxLength',
      path: [],
      input: 'Pluto',
      message: 'Must have the maximum length of 4',
      param: 4,
      meta: undefied
    }
  ]
}

NoteYou can find the list of issue codes and corresponding param values in Validation errors section.

Parameterized checks

You can pass an additional parameter when adding a check:

const includesCheck: d.CheckCallback<string[], string> = (value, param) => {
  if (!value.includes(param)) {
    return { message: 'Must incude ' + param };
  }
};

const shape = d.array(d.string()).check(includesCheck, { param: 'Mars' });
// ⮕ Shape<any[]>

shape.parse(['Mars', 'Pluto']);
// ⮕ ['Mars', 'Pluto']

shape.parse(['Venus']);
// ❌ ValidationError: unknown at /: Must incude Mars

Verbose mode

Doubter halts parsing and raises a validation error as soon as the first issue was encountered. Sometimes you may want to collect all issues that prevent input from being successfully parsed. To do this, pass the verbose option to the parse method.

d.string().max(4).regex(/a/).try('Pluto', { verbose: true });

This would return the Err object with two issues:

{
  ok: false,
  issues: [
    {
      code: 'stringMaxLength',
      path: [],
      input: 'Pluto',
      message: 'Must have the maximum length of 4',
      param: 4,
      meta: undefied
    },
    {
      code: 'stringRegex',
      path: [],
      input: 'Pluto',
      message: 'Must match the pattern /a/',
      param: /a/,
      meta: undefied
    }
  ]
}

Safe and unsafe checks

Checks that you add using a check method are "safe" by default, which means they aren't applied if any of the preceding checks have failed. For example, let's declare the shape of a greeting message:

const helloCheck: d.CheckCallback<string> = value => {
  if (!value.startsWith('Hello')) {
    return { message: 'Must start with Hello' };
  }
};

const noDigitsCheck: d.CheckCallback<string> = value => {
  if (value.match(/\d/)) {
    return { message: 'Must not contain digits' };
  }
};

const shape = d.string()
  .check(helloCheck)
  .check(noDigitsCheck);

If the input violates the helloCheck, then noDigitsCheck isn't applied:

shape.parse('Adiós, R2D2', { verbose: true });
// ❌ ValidationError: type at /: Must start with Hello

To force noDigitsCheck to be applied even if helloCheck has raised issues, pass the unsafe option:

const shape = d.string()
  .check(helloCheck)
  .check(noDigitsCheck, { unsafe: true });

Safe and unsafe checks are applied only if the type of the input is valid.

shape.parse(42);
// ❌ ValidationError: type at /: Must be a number

In the example above both helloCheck and noDigitsCheck are not applied, despite that noDigitsCheck is marked as unsafe. This happens because the input value 42 is of the invalid type.

For composite shapes, unsafe checks may become truly unsafe. Let's consider an object with a custom safe check:

const userShape = d
  .object({
    age: d.number(),
    yearsOfExperience: d.number()
  })
  .check(user => {
    if (user.age < user.yearsOfExperience) {
      return { code: 'inconsistentAge' };
    }
  });
// ⮕ Shape<{ age: number, yearsOfExperience: number }>

The check relies on user to be an object with the valid set of properties. So if any issues are detected in the input object the check won't be called:

// 🟡 Check isn't applied
nameShape.parse({ age: 18 }, { verbose: true });
// ❌ ValidationError: type at /yearsOfExperience: Must be a number

Adding the unsafe option in this case would cause the check to be applied even if object properties are invalid.

Some shapes cannot guarantee that the input value is of the required type. For example, if any of the underlying shapes in an intersection have raised issues, an intersection itself cannot guarantee that its checks would receive the value of the expected type, so it won't apply its unsafe checks.

These shapes won't apply unsafe checks if an underlying shape has raised an issue:

Add, get and delete checks

Let's consider the same check being added to the shape twice:

const emailCheck: d.CheckCallback<string> = value => {
  if (!value.includes('@')) {
    return { code: 'email' };
  }
};

const shape = d.string().check(emailCheck).check(emailCheck);
// ⮕ Shape<string>

Doubter ensures that checks are distinct, so emailCheck check is added to the shape only once.

Retrieve a check:

shape.check(emailCheck);

shape.getCheck(emailCheck);
// ⮕ { key: emailCheck, callback: emailCheck, isUnsafe: false, param: undefined }

Delete a check:

shape.deleteCheck(emailCheck);
// ⮕ Shape<string>

Using a check callback identity as a key isn't always convenient. Pass the key option to define a custom key:

shape.check(emailCheck, { key: 'email' });
// ⮕ Shape<string>

Now you should use the key to get or delete the check:

shape.getCheck('email');
// ⮕ { key: 'email', callback: emailCheck, isUnsafe: false, param: undefined }

shape.deleteCheck('email');
// ⮕ Shape<string>

Doubter considers checks to be identical if they have the same key.

Refinements

Refinements are a simplified checks 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 >= 6);
// ⮕ Shape<string>

shape1.parse('Uranus');
// ⮕ 'Uranus'

shape1.parse('Mars');
// ❌ ValidationError: predicate at /: Must conform the predicate

Use refinements to narrow the output type of the shape:

function isMarsOrPluto(value: string): 'Mars' | 'Pluto' {
  return value === 'Mars' || value === 'Pluto';
}

d.string().refine(isMarsOrPluto)
// ⮕ Shape<string, 'Mars' | 'Pluto'>

By default, refine raises issues with have predicate code. You can provide a custom code:

const shape2 = d.string().refine(
  isMarsOrPluto,
  {
    code: 'unknownPlanet',
    message: 'Must be Mars or Pluto'
  }
);

shape2.parse('Venus');
// ❌ ValidationError: unknownPlanet at /: Must be Mars or Pluto

Transformations

Along with validation, shapes can transform values. Let's consider a shape that takes a string as an input and converts it to a number:

const shape = d.string().transform(parseFloat);
// ⮕ Shape<string, number>

This shape ensures that the input value is a string and passes it to a transformation callback:

shape.parse('42');
// ⮕ 42

shape.parse('seventeen');
// ⮕ NaN

Throw a ValidationError inside the transformation callback to notify parser that transformation cannot be successfully completed:

function toNumber(input: string): number {
  const output = parseFloat(input);

  if (isNaN(output)) {
    throw new d.ValidationError([{ code: 'kaputs' }]);
  }
  return output;
}

const shape = d.string().transform(toNumber);

shape.parse('42');
// ⮕ 42

shape.parse('seventeen');
// ❌ ValidationError: kaputs at /

Async transformations

Let's consider a sync transformation:

const syncShape1 = d.string().transform(
  value => 'Hello, ' + value
);
// ⮕ Shape<string>

syncShape1.isAsync // ⮕ false

syncShape1.parse('Jill');
// ⮕ 'Hello, Jill'

The transformation callback receives and returns a string and so does syncShape1.

Now lets return a promise from the transformation callback:

const syncShape2 = d.string().transform(
  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 transforms it to a Promise<string>. syncShape2 is still sync, since the transformation callback synchronously wraps a value in a promise.

Now let's create an async shape using the async transformation:

const asyncShape1 = d.string().transformAsync(
  value => Promise.resolve('Hello, ' + value)
);
// ⮕ Shape<string>

// 🟡 Notice that the shape is async
asyncShape1.isAsync // ⮕ true

await asyncShape1.parseAsync('Jill');
// ⮕ 'Hello, Jill'

Notice that asyncShape1 still transforms the input string value to output string but the transformation itself is async.

A shape is async if it uses async transformations. Here's an async object shape:

const asyncShape2 = d.object({
  foo: d.string().transformAsync(
    value => Promise.resolve(value)
  )
});
// ⮕ Shape<{ foo: string }>

asyncShape2.isAsync // ⮕ true

NoteComposite shapes are async if they rely on a promise shape:

const shape = d.object({
  foo: d.promise(d.string())
});
// ⮕ Shape<{ foo: Promise<string> }>

shape.isAsync // ⮕ true

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 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 check callbacks, refinement predicates, transformers and fallback functions you can access options passed to the parser. The context option may store arbitrary data, which is undefined by default.

For example, here's how you can use context to transform numbers to formatted strings:

const shape = d.number().transform(
  (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()
  .transform(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(public name: string) {}
}

const shape = d.instanceOf(Planet).to(
  d.object({
    name: d.string().min(4)
  })
);

shape.parse({ name: 'Pluto' });
// ❌ ValidationError: instance at /: Must be a class instance

shape.parse(new Planet('X'));
// ❌ ValidationError: stringMinLength 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 literal value

You can replace an input literal value with an output literal 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().min(3).replace(0, 'zero');
// ⮕ Shape<number | 'zero'>

shape2.parse(2);
// ❌ ValidationError: numberGreaterThan at /: Must be greater than 3

// 🟡 Notice that 0 doesn't satisfy the min constraint
shape2.parse(0);
// ⮕ 'zero'

Allow a literal value

You can allow a literal 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.finite().allow(NaN);
// ⮕ Shape<number>

shape.parse(NaN);
// ⮕ NaN

shape.parse(Infinity);
// ❌ ValidationError: numberFinite at /: Must be a finite number

Deny a literal 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 deny:

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: denied at /: Must not be equal to 42

deny prohibits value for both input and output:

const shape3 = d.number().transform(value => value * 2).deny(42);
// ⮕ Shape<number>

shape3.parse(21);
// ❌ ValidationError: denied 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

Now let's mark this shape as non-optional:

const shape2 = shape1.nonOptional();
// ⮕ Shape<string | number>

shape2.parse(undefined);
// ❌ ValidationError: denied 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: excluded 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 at /name: Must be a string

Deep partial isn't applied to transformed shapes:

const shape2 = d
  .object({
    years: d.array(d.string()).transform(parseFloat)
  })
  .deepPartial();
// ⮕ Shape<{ years?: string[] }, { years?: number[] }>

In the example above, array elements don't allow undefined after deepPartial was applied, this happened because array was transformed.

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:

declare function bookTicket(flightCode: string): void;

// 🟡 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
declare function bookTicket(flightCode: FlightCode): void;

bookTicket(flightCodeShape.parse('BA2490'));
// Ok, valid flight code

bookTicket('Bill');
// ❌ Error: Expected BRAND to be flightCode

NoteBranded types don't affect the runtime result of parse. It is a static-only construct.

Type coercion

Type coercion is the process of converting value from one type to another (such as string to number, array to 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 string type:

const shape1 = d.string().coerce();

shape1.isCoerced // ⮕ true

shape1.parse([8080]);
// ⮕ '8080'

shape1.parse(null);
// ⮕ ''

Coercion can be enabled on shape-by-shape basis (as shown in the example above), or it can be enabled for all shapes when coerced option is passed to parse* or try* methods:

const shape2 = d.object({
  name: d.string(),
  birthday: d.date()
});

shape2.parse(
  {
    name: ['Jake'],
    birthday: '1949-01-24'
  },
  { coerced: true }
);
// ⮕ { name: 'Jake', birthday: new Date(-660700800000) }

Coercion rules differ from JavaScript so the behavior is more predictable and human-like. With Doubter, you can coerce input to the following types:

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 d.any, d.unknown, or d.transform are used:

const shape1 = d.transfrorm(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 literal 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 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.shapes.name;
// ⮕ Shape<number>

const userOrNameShape = d.or([userShape, d.string()]);
// ⮕ Shape<{ name: string, age: number } | string>

userOrNameShape.shapes[0];
// ⮕ userShape

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 custom issue messages:

d.string('Hey, string here').min(3, 'Too short');

Checks that have a param, such as min constraint in the example above, can use a %s placeholder that would be interpolated with the param value.

d.string().min(3, 'Minimum length is %s');

Pass a function as a message, and it would receive a check param, an issue code, an input value, a metadata, and parsing options and should return a formatted message value. The returned formatted message can be of any type.

For example, when using with React you may return a JSX element:

const minimumMessage: d.Message = (param, code, input, meta, options) => (
  <span style={{ color: 'red' }}>
    Minimum length is {param}
  </span>
);

d.number().min(5, minimumMessage);

Semantics described above are applied to the message option as well:

d.string().length(3, { message: 'Expected length is %s' })

Integrations

How to validate an email or UUID? Combine Doubter with your favourite predicate library!

For example, create a shape that validates that input is an email:

import * as d from 'doubter';
import isEmail from 'validator/lib/isEmail';

const emailShape = d.string().refine(isEmail, 'Must be an email');
// ⮕ Shape<string>

emailShape.parse('Not an email');
// ❌ ValidationError: predicate at /: Must be an email

emailShape.parse('foo@bar.com');
// ⮕ 'foo@bar.com'

You can check that the shape describes an email using hasCheck:

emailShape.hasCheck(isEmail);
// ⮕ true

Read more about Refinements and how to Add, get and delete checks.

Advanced shapes

You can create custom shapes by extending the Shape class.

Shape has several protected methods that you can override to alter different aspects of the shape logic.

Synchronous input parsing is delegated to this method. It receives an input that must be parsed and should return the Result:

  • null if the output is the same as the input;
  • Ok if the output contains a new value;
  • an array of Issue objects.

Asynchronous input parsing is delegated to this method. It has the same semantics as _apply but returns a Promise. You need to override this method only if you have a separate logic for async parsing.

The value returned from this method alters what method is used for parsing:

  • if true is returned then _applyAsync would be used for parsing, and _apply would always throw an error;
  • if false is returned then _apply would be used for parsing, and _applyAsync would always redirect to _apply.

Must return an array of types and values that can be processed by the shape. Elements of the returned array don't have to be unique. Refer to Introspection section for more details about types.

Let's create a custom shape that parses an input string as a number:

class NumberLikeShape extends d.Shape<string, number> {

  protected _apply(input: unknown, options: d.ApplyOptions): d.Result<number> {

    // 1️⃣ Validate the input and return issues if it is invalid
    if (typeof input !== 'string' || isNaN(parseFloat(input))) {
      return [{
        code: 'kaputs',
        message: 'Must be a number-like',
        input,
      }];
    }

    // 2️⃣ Prepare the output value
    const output = parseFloat(input);

    // 3️⃣ Apply checks to the output value
    if (this._applyChecks !== null) {
      const issues = this._applyChecks(output, null, options);
      
      if (issues !== null) {
        // 4️⃣ Return issues if the output value is invalid
        return issues;
      }
    }

    // 5️⃣ Return the parsing result
    return { ok: true, value: output };
  }
}

Now let's use this shape alongside with other built-in shapes:

const shape = d.array(new NumberLikeShape());
// ⮕ Shape<string[], number[]>

shape.parse(['42', '33']);
// ⮕ [42, 33]

shape.parse(['seventeen']);
// ❌ ValidationError: kaputs at /0: Must be a number-like

Overriding type coercion

You can extend existing shapes and override type coercion that they implement.

class YesNoShape extends d.BooleanShape {

  protected _coerce(value: unknown): boolean {
    if (value === 'yes') {
      return true;
    }
    if (value === 'no') {
      return false;
    }
    // Coercion is not possible
    return d.NEVER;
  }
}

This shape can be used alongside built-in shapes:

const yesNoShape = new YesNoShape().coerce();

d.array(yesNoShape).parse(['yes', 'no'])
// ⮕ [true, false]

yesNoShape.parse('true')
// ❌ ValidationError: type at /: Must be a boolean

Implementing deep partial support

To enable deepPartial support, your shape must implement DeepPartialProtocol.

class MyShape
  extends Shape
  implements DeepPartialProtocol<MyDeepPartialShape> {

  deepPartial(): MyDeepPartialShape {
    // Create and return a deep partial version of MyShape
  }
}

This is sufficient to enable type inference and runtime support for deepPartial method.

Performance

The chart below showcases the performance comparison in terms of millions of operations per second (greater is better).

Tests were conducted using TooFast.

Here is the performance test suite that produced the results above. To reproduce them, clone this repo and in the repo's root directory use:

npm ci
npm run build
npm run perf -- --testNamePattern Overall

any

any returns a Shape instance.

An unconstrained value that is inferred as any:

d.any();
// ⮕ Shape<any>

Use any to create shapes that are unconstrained at runtime but constrained at compile time:

d.any<{ foo: string }>();
// ⮕ Shape<{ foo: string }>

Create a shape that is constrained by a narrowing predicate:

d.any((value): value is string => typeof value === 'string');
// ⮕ Shape<any, string>

array

array returns an ArrayShape instance.

Constrains a value to be an array:

d.array();
// ⮕ Shape<any[]>

Restrict array element types:

d.array(d.number());
// ⮕ Shape<number[]>

Constrain the length of an array:

d.array(d.string()).min(1).max(10);

Limit both minimum and maximum array length at the same time:

d.array(d.string()).length(5);

Transform array values during parsing:

d.array(d.string().transform(parseFloat));
// ⮕ Shape<string[], number[]>

Coerce to an array

Iterables and array-like objects are converted to array via Array.from(value):

const shape = d.array(d.string()).coerce();

shape.parse(new Set(['John', 'Jack']));
// ⮕ ['John', 'Jack']

shape.parse({ 0: 'Bill', 1: 'Jill', length: 2 });
// ⮕ ['Bill', 'Jill']

Scalars, non-iterable and non-array-like objects are wrapped into an array:

shape.parse('Rose');
// ⮕ ['Rose']

bigint

bigint returns a BigIntShape instance.

Constrains a value to be a bigint.

d.bigint();
// ⮕ Shape<bigint>

Coerce to a bigint

null and undefined are converted to 0:

const shape = d.bigint().coerce();

shape.parse(null);
// ⮕ BigInt(0)

Number, string and boolean values are converted via BigInt(value):

shape.parse('18588');
// ⮕ BigInt(18588)

shape.parse('Unexpected')
// ❌ ValidationError: type at /: Must be a bigint

Arrays with a single element are unwrapped and the value is coerced:

shape.parse([0xdea]);
// ⮕ BigInt(3562)

shape.parse([BigInt(1), BigInt(2)]);
// ❌ ValidationError: type at /: Must be a bigint

boolean, bool

boolean returns a BooleanShape instance.

Constrains a value to be boolean.

d.boolean();
// or
d.bool();
// ⮕ Shape<boolean>

Coerce to a boolean

null, undefined, 'false' and 0 are converted to false:

const shape = d.boolean().coerce();

shape.parse(null);
// ⮕ false

'true' and 1 are converted to true:

shape.parse('true');
// ⮕ true

shape.parse('yes');
// ❌ ValidationError: type at /: Must be a boolean

Arrays with a single element are unwrapped and the value is coerced:

shape.parse([undefined]);
// ⮕ false

shape.parse([0, 1]);
// ❌ ValidationError: type at /: Must be a boolean

const

const returns a ConstShape instance.

Constrains a value to be an exact value:

d.const('Mars');
// ⮕ Shape<'Mars'>

There are shortcuts for null, undefined and nan constants.

Consider using enum if you want a value to be one of multiple literal values.

date

date returns a DateShape instance.

Constrains a value to be a valid date.

d.date();
// ⮕ Shape<Date>

Coerce to a Date

Strings and numbers are converted via new Date(value) and if an invalid date is produced then an issue is raised:

const shape = d.date().coerce();

shape.parse('2023-01-22');
// ⮕ Date

shape.parse('Yesterday');
// ❌ ValidationError: type at /: Must be a Date

Arrays with a single element are unwrapped and the value is coerced:

shape.parse([1674352106419]);
// ⮕ Date

shape.parse(['2021-12-03', '2023-01-22']);
// ❌ ValidationError: type at /: Must be a Date

enum

enum returns an EnumShape instance.

Constrains a value to be equal to one of predefined values:

d.enum(['Mars', 'Pluto', 'Jupiter']);
// ⮕ Shape<'Mars', 'Pluto', 'Jupiter'>

Or use a native TypeScript enum to limit possible values:

enum Planet {
  MARS,
  PLUTO,
  JUPITER
}

d.enum(Planet);
// ⮕ Shape<Planet>

Or use an object with a const assertion:

const planets = {
  MARS: 'Mars',
  PLUTO: 'Pluto',
  JUPITER: 'Jupiter'
} as const;

d.enum(plants);
// ⮕ Shape<'Mars', 'Pluto', 'Jupiter'>

Coerce to an enum

If an enum is defined via a native TypeScript enum or via a const object, then enum element names are coerced to corresponding values:

enum Users {
  JILL,
  SARAH,
  JAMES
}

const shape = d.enum(Users).coerce();

shape.parse('SARAH');
// ⮕ 1

Arrays with a single element are unwrapped and the value is coerced:

shape.parse(['JAMES']);
// ⮕ 2

shape.parse([1]);
// ⮕ 1

shape.parse([1, 2]);
// ❌ ValidationError: enum at /: Must be equal to one of 0,1,2

finite

finite returns a NumberShape instance.

Constrains a value to be a finite number.

d.finite();
// ⮕ Shape<number>

This is a shortcut for a number shape declaration:

d.number().finite();
// ⮕ Shape<number>

Finite numbers follow number type coercion rules.

function, fn

function returns a FunctionShape instance.

Constrain a value to be a function that has an ensured signature at runtime.

A function that has no arguments and returns any:

d.function()
// ⮕ Shape<() => any>

// or use a shorter alias
d.fn();

Provide an array of argument shapes:

d.fn([d.string(), d.number()]);
// ⮕ Shape<(arg1: string, arg2: number) => any>

Or provide a shape that constrains an array of arguments:

d.fn(d.array(d.string()));
// ⮕ Shape<(...args: string[]) => any>

Any shape that constrains an array type would do, you can even use a union:

d.fn(
  d.or([
    d.array(d.string()),
    d.tuple([d.string(), d.number()])
  ])
);
// ⮕ Shape<(...args: string[] | [string, number]) => any>

To constrain the return value of a function shape, use the return method.

d.fn().return(d.string());
// ⮕ Shape<() => string>

To constrain a value of this:

d.fn().this(
  d.object({ userId: d.string })
);
// ⮕ Shape<(this: { userId: string }) => any>

Parsing a function

Function shapes check that an input value is a function:

const shape1 = d.fn();

shape1.parse(() => 42);
// ⮕ () => any

shape1.parse('Mars');
// ❌ ValidationError: type at /: Must be a function

By default, the input function is returned as is during parsing. Tell the function shape to wrap the input function with a signature ensurance wrapper during parsing by calling strict method.

const greetShape = d.fn([d.string()])
  .return(d.string())
  .strict();

const greet = greetShape.parse(name => `Hello, $name!`);

greet guarantees that the input function is called with arguments, this and return values that conform the respective shapes.

Implementing a function

You can wrap an input function with a signature ensurance wrapper that guarantees that the function signature is type-safe at runtime.

Let's declare a function shape that takes two integers arguments and returns an integer as well:

const sumShape = d.fn([d.int(), d.int()]).return(d.int());
// ⮕ Shape<(arg1: number, arg2: number) => number>

Now let's provide a concrete implementation:

const sum = sumShape.ensureSignature(
  (arg1, arg2) => arg1 + arg2
);
// ⮕ (arg1: number, arg2: number) => number

sum(2, 3);
// ⮕ 5

sum would throw a ValidationError if the required signature is violated at runtime:

sum(2, '3');
// ❌ ValidationError: type at /arguments/1: Must be a number

sum(3.14, 2);
// ❌ ValidationError: numberInteger at /arguments/0: Must be an integer

sum(1, 2, 3);
// ❌ ValidationError: arrayMaxLength at /arguments: Must have the maximum length of 2

Using function shape you can parse this and return values.

const atShape = d.fn([d.int()])
  .this(d.array(d.string()))
  .return(d.number());
// ⮕ Shape<(this: string[]) => number>

const at = atShape.ensureSignature(function (index) {
  // 🟡 May be undefined if index is out of bounds
  return this[index];
});
// ⮕ (this: number[]) => number

When called with a valid index, a string is returned:

at.call(['Jill', 'Sarah'], 1);
// ⮕ 'Sarah'

But if an index is out of bounds, an error is thrown:

at.call(['James', 'Bob'], 33);
// ❌ ValidationError: type at /return: Must be a string

An error is thrown if an argument isn't an integer:

at.call(['Bill', 'Tess'], 3.14);
// ❌ ValidationError: numberInteger at /arguments/0: Must be an integer

Coercing arguments

Function shapes go well with type coercion:

const plus2Shape = d.fn([d.int().coerce()]).return(d.int());
// ⮕ Shape<(arg: number) => number>

const plus2 = plus2Shape.ensureSignature(
  arg => arg + 2
);
// ⮕ (arg: number) => number

While plus2 requires a single integer parameter, we can call it at runtime with a number-like string and get an expected numeric result because an argument is coerced:

plus2('40');
// ⮕ 42

Transforming arguments and return values

Here's a function shape that transforms the input argument by parsing a string as a number:

const shape = d.fn([d.string().transform(parseFloat)]);
// ⮕ Shape<(arg: number) => any, (arg: string) => any>

Note that the input and output functions described by this shape have different signatures. Let's implement of this function:

function inputFunction(arg: number): any {
  return arg + 2;
}

const outputFunction = shape.ensureSignature(inputFunction);
// ⮕ (arg: string) => any

The pseudocode below demonstrates the inner workings of the outputFunction:

function outputFunction(...inputArguments) {

  const outputThis = shape.thisShape.parse(this);

  const outputArguments = shape.argsShape.parse(inputArguments);

  const inputResult = inputFunction.apply(outputThis, outputArguments);
  
  const outputResult = shape.resultShape.parse(inputResult);
  
  return outputResult;
}

instanceOf

instanceOf returns an InstanceShape instance.

Constrains a value to be an object that is an instance of a class:

class User {
  name?: string;
}

d.instanceOf(User);
// ⮕ Shape<User>

integer, int

integer returns a NumberShape instance.

Constrains a value to be an integer.

d.integer().min(5);
// ⮕ Shape<number>

d.int().max(5);
// ⮕ Shape<number>

This is a shortcut for number shape declaration:

d.number().integer();
// ⮕ Shape<number>

Integers follow number type coercion rules.

intersection, and

intersection returns an IntersectionShape instance.

Creates a shape that checks that the input value conforms to all shapes.

d.intersection([
  d.object({
    name: d.string()
  }),
  d.object({
    age: d.number()
  })
]);
// ⮕ Shape<{ name: string } & { age: number }>

Or use a shorter alias and:

d.and([
  d.array(d.string()),
  d.array(d.enum(['Peter', 'Paul']))
]);
// ⮕ Shape<string[] & Array<'Peter' | 'Paul'>>

Intersecting objects

When working with objects, extend objects instead of intersecting them whenever possible, since object shapes are more performant than object intersection shapes.

There's a logical difference between extended and intersected objects. Let's consider two shapes that both contain the same key:

const shape1 = d.object({
  foo: d.string(),
  bar: d.boolean(),
});

const shape2 = d.object({
  // 🟡 Notice that the type of foo property in shape2 differs from shape1.
  foo: d.number()
});

When you extend an object properties of the left object are overwritten with properties of the right object:

const shape = shape1.extend(shape2);
// ⮕ Shape<{ foo: number, bar: boolean }>

The intersection requires the input value to conform both shapes at the same time, it's not possible since there are no values that can satisfy the string | number type. So the type of property foo becomes never and no value would be able to satisfy the resulting intersection shape.

const shape = d.and([shape1, shape2]);
// ⮕ Shape<{ foo: never, bar: boolean }>

lazy

lazy returns a LazyShape instance.

With lazy you can declare recursive shapes. To showcase how to use it, let's create a shape that validates JSON data:

type JSON =
  | number
  | string
  | boolean
  | null
  | JSON[]
  | { [key: string]: JSON };

const jsonShape: d.Shape<JSON> = d.lazy(() =>
  d.or([
    d.number(),
    d.string(),
    d.boolean(),
    d.null(),
    d.array(jsonShape),
    d.record(jsonShape)
  ])
);

jsonShape.parse({ name: 'Jill' });
// ⮕ { name: 'Jill' }

jsonShape.parse({ tag: Symbol() });
// ❌ ValidationError: intersection at /tag: Must conform the intersection

Note that the JSON type is defined explicitly, because it cannot be inferred from the shape which references itself directly in its own initializer.

WarningWhile Doubter supports cyclic types, it doesn't support cyclic data structures. The latter would cause an infinite loop at runtime.

map

map returns a MapShape instance.

Constrains an input to be a Map instance:

d.map(d.string(), d.number());
// ⮕ Shape<Map<string, number>>

Coerce to a Map

Arrays, iterables and array-like objects that withhold entry-like elements (a tuple with two elements) are converted to Map entries via Array.from(value):

const shape = d.map(d.string(), d.number()).coerce();

shape.parse([
  ['Mars', 0.1199],
  ['Pluto', 5.3361]
]);
// ⮕ Map { 'Mars' → 0.1199, 'Pluto' → 5.3361 }

shape.parse(['Jake', 'Bill']);
// ❌ ValidationError: type at /: Must be a Map

Other objects are converted to an array of entries via new Map(Object.entries(value)):

shape.parse({
  Jake: 31,
  Jill: 28
});
// ⮕ Map { 'Jake' → 31, 'Jill' → 28 }

nan

nan returns a ConstShape instance.

A shape that requires an input to be NaN:

d.nan();
// ⮕ Shape<number>

If you want to constrain a number and allow NaN values, use number:

d.number().nan();
// ⮕ Shape<number>

never

never returns a NeverShape instance.

A shape that always raises a validation issue regardless of an input value:

d.never();
// ⮕ Shape<never>

not

not returns an ExcludeShape instance.

A shape that allows any value that doesn't conform the negated shape:

const shape = d.not(d.string())
// ⮕ Shape<any>

shape.parse(42);
// ⮕ 42

shape.parse('Bill');
// ❌ ValidationError: excluded at /: Must not conform the excluded shape

More about exclusions in the Exclude a shape section.

null

null returns a ConstShape instance.

A shape that requires an input to be null:

d.null();
// ⮕ Shape<null>

number

number returns a NumberShape instance.

A shape that requires an input to be a number.

d.number();
// ⮕ Shape<number>

Allow NaN input values:

d.number().nan();
// ⮕ Shape<number>

Replace NaN with a default value:

d.number().nan(0).parse(NaN);
// ⮕ 0

Limit the allowed range:

// The number must be greater than 5 and less then or equal to 10
d.number().gt(0.5).lte(2.5)
// ⮕ Shape<number>

Constrain a number to be a multiple of a divisor:

// Number must be divisible by 5 without a remainder
d.number().multipleOf(5);

Constrain the number to be an integer:

d.number().integer();
// or
d.int();

Constrain the input to be a finite number (not NaN, Infinity or -Infinity):

d.number().finite();
// or
d.finite()

The finite and integer assertions are always applied before other checks.

Coerce to a number

null and undefined values are converted to 0:

const shape = d.number().coerce();

shape.parse(null);
// ⮕ 0

Strings, boolean values and Date objects are converted using +value:

shape.parse('42');
// ⮕ 42

shape.parse('seventeen');
// ❌ ValidationError: type at /: Must be a number

Arrays with a single element are unwrapped and the value is coerced:

shape.parse([new Date('2023-01-22')]);
// ⮕ 1674345600000

shape.parse([1997, 1998]);
// ❌ ValidationError: type at /: Must be a number

object

object returns an ObjectShape instance.

Constrains a value to be an object with a set of properties:

d.object({
  name: d.string(),
  age: d.number()
});
// ⮕ Shape<{ name: string, age: number }>

Optional properties

If the inferred type of the property shape is a union with undefined then the property becomes optional:

d.object({
  name: d.string().optional(),
  age: d.number()
});
// ⮕ Shape<{ name?: string | undefined, age: number }>

Or you can define optional properties as a union:

d.object({
  name: d.or([d.string(), d.undefined()]),
});
// ⮕ Shape<{ name?: string | undefined }>

If the transformation result extends undefined then the output property becomes optional:

d.object({
  name: d.string().transform(
    value => value !== 'Google' ? value : undefined
  ),
});
// ⮕ Shape<{ name: string }, { name?: string | undefined }>

Index signature

Add an index signature to the object type, so all properties that aren't listed explicitly are validated with the rest shape:

const shape = d.object({
  foo: d.string(),
  bar: d.number()
});
// ⮕ Shape<{ foo: string, bar: number }>

const restShape = d.or([
  d.string(),
  d.number()
]);
// ⮕ Shape<string | number>

shape.rest(restShape);
// ⮕ Shape<{ foo: string, bar: number, [key: string]: string | number }>

Unlike an index signature in TypeScript, a rest shape is applied only to keys that aren't explicitly specified among object property shapes.

Unknown keys

Keys that aren't defined explicitly can be handled in several ways:

  • constrained by the rest shape;
  • stripped;
  • preserved as is, this is the default behavior;
  • prohibited.

Force an object to have only known keys. If an unknown key is met, a validation issue is raised.

d.object({
  foo: d.string(),
  bar: d.number()
}).exact();

Strip unknown keys, so the object is cloned if an unknown key is met, and only known keys are preserved.

d.object({
  foo: d.string(),
  bar: d.number()
}).strip();

Derive the new shape and override the strategy for unknown keys:

const shape = d.object({ foo: d.string() }).exact();

// Unknonwn keys are now preserved
shape.preserve();

Picking and omitting properties

Picking keys from an object creates the new shape that contains only listed keys:

const shape1 = d.object({
  foo: d.string(),
  bar: d.number()
});

const shape2 = shape1.pick(['foo']);
// ⮕ Shape<{ foo: string }>

Omitting keys of an object creates the new shape that contains all keys except listed ones:

const shape = d.object({
  foo: d.string(),
  bar: d.number()
});

shape.omit(['foo']);
// ⮕ Shape<{ bar: number }>

Extending objects

Add new properties to the object shape:

const shape = d.object({
  name: d.string()
});

shape.extend({
  age: d.number()
});
// ⮕ Shape<{ name: string, age: number }>

Merging object shapes preserves the index signature of the left-hand shape:

const fooShape = d.object({
  foo: d.string()
}).rest(d.or([d.string(), d.number()]));

const barShape = d.object({
  bar: d.number()
});

fooShape.extend(barShape);
// ⮕ Shape<{ foo: string, bar: number, [key: string]: string | number }>

Making objects partial and required

Object properties are optional if their type extends undefined. Derive an object shape that would have its properties all marked as optional:

const shape1 = d.object({
  foo: d.string(),
  bar: d.number()
});

shape1.partial()
// ⮕ Shape<{ foo?: string | undefined, bar?: number | undefined }>

Specify which fields should be marked as optional:

const shape2 = d.object({
  foo: d.string(),
  bar: d.number()
});

shape2.partial(['foo'])
// ⮕ Shape<{ foo?: string | undefined, bar: number }>

In the same way, properties that are optional can be made required:

const shape3 = d.object({
  foo: d.string().optional(),
  bar: d.number()
});

shape3.required(['foo'])
// ⮕ Shape<{ foo: string, bar: number }>

Note that required would force the value of both input and output to be non-undefined.

Object keys

Derive a shape that constrains keys of an object:

const shape = d.object({
  name: d.string(),
  age: d.number()
});

const keyShape = shape.keyof();
// ⮕ Shape<'name' | 'age'>

promise

promise returns a PromiseShape instance.

A shape that constrains to the resolved value of a Promise.

d.promise(d.string());
// ⮕ Shape<Promise<string>>

Transform the value inside a promise:

const shape = d.promise(
  d.string().transform(parseFloat)
);
// ⮕ Shape<Promise<string>, Promise<number>>

Coerce to a Promise

All values are converted to a promise by wrapping it in Promise.resolve():

const shape = d.promise(d.number()).coerce();

shape.parseAsync(42);
// ⮕ Promise<number>

record

record returns a RecordShape instance.

Constrain keys and values of a dictionary-like object:

d.record(d.number())
// ⮕ Shape<Record<string, number>>

Constrain both keys and values of a dictionary-like object:

d.record(d.string(), d.number())
// ⮕ Shape<Record<string, number>>

Pass any shape that extends Shape<string> as a key constraint:

const keyShape = d.enum(['foo', 'bar']);
// ⮕ Shape<'foo' | 'bar'>

d.record(keyShape, d.number());
// ⮕ Shape<Record<'foo' | 'bar', number>>

set

set returns a SetShape instance.

Constrains an input to be a Set instance:

d.set(d.number());
// ⮕ Shape<Set<number>>

Constrain the size of a Set:

d.set(d.string()).min(1).max(10);

Limit both minimum and maximum size at the same time:

d.set(d.string()).size(5);

Coerce to a Set

Arrays, iterables and array-like objects converted to Set values via Array.from(value):

const shape = d.set(d.string()).coerce();

shape.parse(['Boris', 'K']);
// ⮕ Set { 'Boris', 'K' }

Scalars, non-iterable and non-array-like objects are wrapped into an array:

shape.parse('J');
// ⮕ Set { 'J' }

string

string returns a StringShape instance.

Constrains a value to be string.

d.string();
// ⮕ Shape<string>

Constrain the string length limits:

d.string().min(1).max(10);

Limit both minimum and maximum string length at the same time:

d.string().length(5);

Constrain a string with a regular expression:

d.string().regex(/foo|bar/);

Coerce to a string

null and undefined are converted to an empty string:

const shape = d.string().coerce();

shape.parse(null);
// ⮕ ''

Finite numbers, boolean and bigint values are converted via String(value):

shape.parse(BigInt(2398955));
// ⮕ '2398955'

shape.parse(8080);
// ⮕ '8080'

shape.parse(-Infinity);
// ❌ ValidationError: type at /: Must be a string

Valid dates are converted to an ISO formatted string:

shape.parse(new Date(1674352106419));
// ⮕ '2023-01-22T01:48:26.419Z'

shape.parse(new Date(NaN));
// ❌ ValidationError: type at /: Must be a string

Arrays with a single element are unwrapped and the value is coerced:

shape.parse([undefined]);
// ⮕ ''

shape.parse(['Jill', 'Sarah']);
// ❌ ValidationError: type at /: Must be a string

symbol

symbol returns a SymbolShape instance.

A shape that constrains a value to be an arbitrary symbol.

d.symbol();
// ⮕ Shape<symbol>

To constrain an input to an exact symbol, use const:

const TAG = Symbol('tag');

d.const(TAG);
// ⮕ Shape<typeof TAG>

Or use an enum to allow several exact symbols:

const FOO = Symbol('foo');
const BAR = Symbol('bar');

d.enum([FOO, BAR]);
// ⮕  Shape<typeof FOO | typeof BAR>

transform, transformAsync

Both transform and transformAsync return a TransformShape instance.

Transforms the input value:

const shape = d.transform(parseFloat);
// ⮕ Shape<any, number>

Use transform in conjunction with shape piping:

shape.to(d.number().min(3).max(5));

Apply async transformations with transformAsync:

d.transformAsync(value => Promise.resolve('Hello, ' + value));
// ⮕ Shape<any, string>

For more information, see Transformations section.

tuple

tuple returns an ArrayShape instance.

Constrains a value to be a tuple where elements at particular positions have concrete types:

d.tuple([d.string(), d.number()]);
// ⮕ Shape<[string, number]>

Specify a rest tuple elements:

d.tuple([d.string(), d.number()], d.boolean());
// ⮕ Shape<[string, number, ...boolean]>

// Or
d.tuple([d.string(), d.number()]).rest(d.boolean());
// ⮕ Shape<[string, number, ...boolean]>

Tuples follow array type coercion rules.

undefined

undefined returns a ConstShape instance.

A shape that requires an input to be undefined:

d.undefined();
// ⮕ Shape<undefined>

union, or

union returns a UnionShape instance.

A constraint that allows a value to be one of the given types:

d.union([d.string(), d.number()]);
// ⮕ Shape<string | number>

Use a shorter alias or:

d.or([d.string(), d.number()]);

Discriminated unions

A discriminated union is a union of object shapes that all share a particular key.

Doubter automatically applies various performance optimizations to union shapes and discriminated union detection is one of them. As an example, let's create a discriminated union of objects representing various business types.

Sole entrepreneur goes first:

const entrepreneurShape = d.object({
  bisinessType: d.const('entrepreneur'),
  name: d.string(),
  age: d.int().gte(18)
});
// ⮕ Shape<{ type: 'entrepreneur', name: string, age: number }>

We're going to use bisinessType property as the discriminator in our union. Now let's define a shape for a company:

const companyShape = d.object({
  businessType: d.or([
    d.const('llc'),
    d.enum(['corporation', 'partnership'])
  ]),
  headcount: d.int().positive()
});
// ⮕ Shape<{ type: 'llc' | 'corporation' | 'partneership', headcount: number }>

Notice that we declared businessType as a composite shape. This would work just fine until shape restricts its input to a set of literal values.

The final step is to define a discriminated union shape:

const businessShape = d.union([entrepreneurShape, companyShape]);

union would detect that all object shapes in the union have the businessType property with distinct values and would enable a discriminated union optimization.

Discriminated unions raise fewer issues because only one shape from the union can be applied to an input:

businessType.parse({
  businessType: 'corporation',
  headcount: 0
});
// ❌ ValidationError: numberGreaterThan at /headcount: Must be greater than 0

Issues raised by a union

If there are multiple shapes in the union that have raised issues during parsing, then union returns a grouping issue.

const shape = d.or([
  d.object({
    name: d.string()
  }),
  d.object({
    age: d.number()
  })
]);
// ⮕ Shape<{ name: string } | { age: number }>

shape.try({ name: 47, age: null });

The result of try would contain a grouping issue:

{
  code: 'union',
  path: [],
  input: {
    name: 47,
    age: null
  },
  message: 'Must conform the union',
  param: {
    inputs: [Type.OBJECT],
    issueGroups: [
      [
        {
          code: 'type',
          path: ['name'],
          input: 47,
          message: 'Must be a string',
          param: 'string'
        }
      ],
      [
        {
          code: 'type',
          path: ['age'],
          message: 'Must be a number',
          param: 'number'
        }
      ]
    ]
  }
}

An array of all input types and literal values that the union accepts.

An array of issue groups where each group contains issues raised by a separate shape in the union; or null.

Union checks the input only against shapes that accept the input value type. If there were no shapes in the union that accept the provided input value type, then issueGroups is null. For example, if you have a number | string union and parse a boolean value, there's no shape that accepts boolean input type. So the raised union issue would have issueGroups set to null.

path of issues in issueGroups is relative to the grouping issue.

When union detects that only one of its shapes accepts the provided input value then issues produced by this shape are returned as is:

d.or([d.number(), d.string().min(6)]).try('Okay')

In this example, only d.string can parse the 'Okay' input value, so the result of try would contain a single string-related issue:

{
  code: 'stringMinLength',
  path: [],
  input: 'Okay',
  message: 'Must have the minimum length of 6',
  param: 6
}

This behaviour is applied to discriminated unions as well.

unknown

unknown returns a Shape instance.

An unconstrained value that is inferred as unknown:

d.unknown();
// ⮕ Shape<unknown>

void

void returns a ConstShape instance.

A shape that requires an input to be undefined that is typed as void:

d.void();
// ⮕ Shape<void>

Cookbook

Rename object keys

First, create a shape that describes the key transformation. In this example we are going to transform the enumeration of keys to uppercase alternatives:

const keyShape = d.enum(['foo', 'bar']).transform(
  value => value.toUpperCase() as 'FOO' | 'BAR'
);
// ⮕ Shape<'foo' | 'bar', 'FOO' | 'BAR'>

Then, create a d.record shape that constrains keys and values or a dictionary-like object:

const shape = d.record(keyShape, d.number());
// ⮕ Shape<Record<'foo' | 'bar', number>, Record<'FOO' | 'BAR', number>>

Parse the input object, the output would be a new object with transformed keys:

shape.parse({ foo: 1, bar: 2 });
// ⮕ { FOO: 1, BAR: 2 }

Type-safe URL query params

Let's define a shape that describes the query with name and age params:

const queryShape = d
  .object({
    name: d.string(),
    age: d.int().nonNegative().catch()
  })
  .partial();
// ⮕ Shape<{ name?: string | undefined, age?: number | undefined }>

Key takeaways:

  1. The object shape is marked as partial, so absence of any query param won't raise a validation issue. You can mark individual params as optional and provide a default value.

  2. Query params are strings. So name doesn't require additional attention since it's constrained by d.string. On the other hand, age is an integer, so type coercion must be enabled to coerce age to a number. To do this we're going to pass the coerced option to the parse method.

  3. We also added catch, so when age cannot be parsed as a positive integer, Doubter returns undefined instead of raising a validation issue.

Now, let's parse the query string with qs and then apply our shape:

import qs from 'qs';

const query = queryShape.parse(
  qs.parse('name=Frodo&age=50'),
  { coerced: true }
);
// ⮕ { name: 'Frodo', age: 50 }

age is set to undefined if it is invalid:

queryShape.parse(
  qs.parse('age=-33'),
  { coerced: true }
);
// ⮕ { age: undefined }

Type-safe env variables

If you're developing an app that consumes environment variables you most likely want to validate them.

const envShape = d
  .object({
    NODE_ENV: d.enum(['test', 'production']),
    DAYS_IN_YEAR: d.int().optional(365),
  })
  .strip();

Key takeaways:

  1. Env variables are strings, so type coercion must be enabled to coerce DAYS_IN_YEAR to an integer.

  2. NODE_ENV is the required env variable, while DAYS_IN_YEAR is optional and would be set to 365 if it isn't available. Note that if DAYS_IN_YEAR is provided and isn't a valid integer, a validation error would be raised.

  3. Unknown env variables are stripped, so they won't be visible inside the app. This prevents accidental usage of an unvalidated env variable.

const env = envShape.parse(
  process.env,
  { coerced: true }
);
// ⮕ { NODE_ENV: 'test' | 'production', DAYS_IN_YEAR: number }

Conditionally applied shapes

If you need to apply a different shape depending on an input value, you can use transform.

const stringShape = d.string().min(5);

const numberShape = d.number().positive();

const shape = d.transform(value => {
  if (typeof value === 'string') {
    return stringShape.parse(value)
  } else {
    return numberShape.parse(value);
  }
});

parse would throw a ValidationError that is captured by the enclosing transform.

shape.parse('Uranus');
// ⮕ 'Mars'

shape.parse('Mars');
// ❌ ValidationError: stringMinLength at /: Must have the minimum length of 5

shape.parse(42);
// ⮕ 42

shape.parse(-273.15);
// ❌ ValidationError: numberGreaterThan at /: Must be greater than 0
4.0.3-next.98643ae

2 months ago

4.0.2-next.cd8641f

3 months ago

4.0.3

3 months ago

4.0.3-next.f602eb1

3 months ago

4.0.2-next.049e1a6

3 months ago

4.0.2-next.f107cbf

5 months ago

4.0.2

5 months ago

3.0.2-next.5d31239

8 months ago

2.1.0-next.db50a32

9 months ago

3.0.3-next.9d910e2

5 months ago

3.0.3-next.fc38054

6 months ago

3.0.3-next.44cc1b7

6 months ago

3.0.3-next.b84bdd4

7 months ago

3.0.2-next.1658a30

8 months ago

3.0.3-next.a8af04c

7 months ago

3.0.3

8 months ago

3.0.2

8 months ago

3.0.1

9 months ago

3.0.1-next.2481238

9 months ago

3.0.3-next.a7853be

7 months ago

3.0.2-next.bc28b80

8 months ago

3.0.0-next.c11bd69

9 months ago

3.0.0

9 months ago

3.0.2-next.d158ace

8 months ago

3.0.1-next.98edda2

8 months ago

4.0.1

5 months ago

4.0.0

5 months ago

3.0.3-next.600d994

5 months ago

3.0.2-next.5cf5236

8 months ago

3.0.2-next.459b5c9

8 months ago

2.1.0-next.5541ac9

9 months ago

3.0.2-next.cb753bf

8 months ago

3.0.1-next.9abdfdd

9 months ago

3.0.2-next.6c31d91

8 months ago

3.0.3-next.0e9d2bc

5 months ago

4.0.0-next.1271ec6

5 months ago

3.0.0-next.06f469a

9 months ago

2.1.0-next.2ef410b

9 months ago

2.1.0-next.5375292

10 months ago

2.1.0-next.3e8ab76

10 months ago

3.0.3-next.2b13848

8 months ago

4.0.0-next.273fb8e

5 months ago

3.0.2-next.36c0694

8 months ago

4.0.1-next.b80c096

5 months ago

3.0.3-next.64efd8d

5 months ago

3.0.3-next.27b380c

5 months ago

3.0.1-next.49db2f3

9 months ago

2.1.0-next.acb458d

9 months ago

2.1.0-next.b438e63

9 months ago

4.0.0-next.45f48cc

5 months ago

3.0.3-next.0f8cde8

6 months ago

3.0.3-next.e6197ac

5 months ago

3.0.0-next.7a69993

9 months ago

2.1.0-next.229bd5a

10 months ago

2.1.0-next.9a90d7c

11 months ago

2.1.0-next.4474333

11 months ago

2.1.0-next.d04aead

11 months ago

2.1.0-next.11ed692

11 months ago

2.1.0-next.8600b13

11 months ago

2.1.0-next.e4258d6

11 months ago

2.1.0-next.8800943

11 months ago

2.1.0-next.92c3dea

11 months ago

2.1.0-next.ef364a0

11 months ago

2.1.0-next.bd8bf19

11 months ago

2.1.0-next.51c3ee6

11 months ago

2.1.0

1 year ago

2.0.0

1 year ago

1.2.0

1 year ago

1.1.1

1 year ago

1.1.0

1 year ago

1.0.1

1 year ago

1.0.0

1 year ago

0.2.1

2 years ago

0.2.0

2 years ago

0.1.0

2 years ago