Skip to content

Commit

Permalink
Make ValidationError a discriminated union
Browse files Browse the repository at this point in the history
  • Loading branch information
gustavohenke committed Jan 10, 2023
1 parent a927c07 commit 439220d
Show file tree
Hide file tree
Showing 18 changed files with 157 additions and 94 deletions.
97 changes: 57 additions & 40 deletions src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,46 +66,63 @@ export interface FieldInstance {
originalValue: any;
}

export type ValidationError =
| {
path: '_error';

/**
* The error message
*/
msg: any;

/**
* The list of underlying validation errors returned by validation chains in `oneOf()`
*/
nestedErrors: ValidationError[];
// These are optional so places don't need to define them, but can reference them
location?: undefined;
value?: undefined;
}
| {
/**
* The location within the request where this field is
*/
location: Location;

/**
* The path to the field which has a validation error
*/
path: string;

/**
* The value of the field
*/
value: any;

/**
* The error message
*/
msg: any;
// This is optional so places don't need to define it, but can reference it
nestedErrors?: unknown[];
};
export type FieldValidationError = {
/**
* Indicates that the error occurred because a field had an invalid value
*/
type: 'field';

/**
* The location within the request where this field is
*/
location: Location;

/**
* The path to the field which has a validation error
*/
path: string;

/**
* The value of the field
*/
value: any;

/**
* The error message
*/
msg: any;
};

export type AlternativeValidationError = {
/**
* Indicates that the error occurred because all alternatives (e.g. in `oneOf()`) were invalid
*/
type: 'alternative';

/**
* The error message
*/
msg: any;

/**
* The list of underlying validation errors returned by validation chains in `oneOf()`
*/
nestedErrors: FieldValidationError[];
};

/**
* A validation error as reported by a middleware.
* The properties available in the error object vary according to the type.
*
* @example
* if (error.type === 'alternative') {
* console.log(`There are ${error.nestedErrors.length} errors under this alternative list`);
* } else if (error.type === 'field') {
* console.log(`There's an error with field ${error.path) in the request ${error.location}`);
* }
*
*/
export type ValidationError = AlternativeValidationError | FieldValidationError;

// Not using Symbol because of #813
export const contextsKey = 'express-validator#contexts';
Expand Down
2 changes: 1 addition & 1 deletion src/chain/context-runner-impl.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ afterEach(() => {
it('returns Result for current context', async () => {
builder.addItem({
async run(context, value, meta) {
context.addError({ type: 'single', value, meta });
context.addError({ type: 'field', value, meta });
},
});
const result = await contextRunner.run({});
Expand Down
1 change: 1 addition & 0 deletions src/chain/validators-impl.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,7 @@ describe('correctly merges validator.matches flags', () => {

describe('always correctly validates with validator.matches using the g flag', () => {
const expectedErr = {
type: 'field',
value: 'fo157115',
msg: 'INVALID USER FORMAT',
path: 'user',
Expand Down
2 changes: 1 addition & 1 deletion src/context-items/bail.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ it('does not throw if the context has no errors', () => {
it('throws a validation halt if the context has errors', () => {
const context = new ContextBuilder().build();
context.addError({
type: 'single',
type: 'field',
message: 'foo',
value: 'value',
meta: {
Expand Down
12 changes: 6 additions & 6 deletions src/context-items/custom-validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const createSyncTest = (options: { returnValue: any; addsError: boolean }) => as
await validation.run(context, 'bar', meta);
if (options.addsError) {
expect(context.addError).toHaveBeenCalledWith({
type: 'single',
type: 'field',
message: validation.message,
value: 'bar',
meta,
Expand Down Expand Up @@ -59,7 +59,7 @@ describe('when not negated', () => {
});
await validation.run(context, 'bar', meta);
expect(context.addError).toHaveBeenCalledWith({
type: 'single',
type: 'field',
message: 'nope',
value: 'bar',
meta,
Expand All @@ -70,7 +70,7 @@ describe('when not negated', () => {
validator.mockRejectedValue('a bomb');
await validation.run(context, 'bar', meta);
expect(context.addError).toHaveBeenCalledWith({
type: 'single',
type: 'field',
message: 'nope',
value: 'bar',
meta,
Expand All @@ -89,7 +89,7 @@ describe('when not negated', () => {
});
await validation.run(context, 'bar', meta);
expect(context.addError).toHaveBeenCalledWith({
type: 'single',
type: 'field',
message: 'boom',
value: 'bar',
meta,
Expand All @@ -100,7 +100,7 @@ describe('when not negated', () => {
validator.mockRejectedValue('a bomb');
await validation.run(context, 'bar', meta);
expect(context.addError).toHaveBeenCalledWith({
type: 'single',
type: 'field',
message: 'a bomb',
value: 'bar',
meta,
Expand Down Expand Up @@ -149,7 +149,7 @@ describe('when negated', () => {
validator.mockResolvedValue(true);
await validation.run(context, 'bar', meta);
expect(context.addError).toHaveBeenCalledWith({
type: 'single',
type: 'field',
message: 'nope',
value: 'bar',
meta,
Expand Down
4 changes: 2 additions & 2 deletions src/context-items/custom-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ export class CustomValidation implements ContextItem {
// A promise that was resolved only adds an error if negated.
// Otherwise it always suceeds
if ((!isPromise && failed) || (isPromise && this.negated)) {
context.addError({ type: 'single', message: this.message, value, meta });
context.addError({ type: 'field', message: this.message, value, meta });
}
} catch (err) {
if (this.negated) {
return;
}

context.addError({
type: 'single',
type: 'field',
message: this.message || (err instanceof Error ? err.message : err),
value,
meta,
Expand Down
2 changes: 1 addition & 1 deletion src/context-items/standard-validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const createTest = (options: { returnValue: any; addsError: boolean }) => async
await validation.run(context, 'bar', meta);
if (options.addsError) {
expect(context.addError).toHaveBeenCalledWith({
type: 'single',
type: 'field',
message: validation.message,
value: 'bar',
meta,
Expand Down
2 changes: 1 addition & 1 deletion src/context-items/standard-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export class StandardValidation implements ContextItem {
values.forEach(value => {
const result = this.validator(this.stringify(value), ...this.options);
if (this.negated ? result : !result) {
context.addError({ type: 'single', message: this.message, value, meta });
context.addError({ type: 'field', message: this.message, value, meta });
}
});
}
Expand Down
34 changes: 21 additions & 13 deletions src/context.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { FieldInstance, FieldValidationError, Meta } from './base';
import { Context } from './context';
import { ContextBuilder } from './context-builder';
import { FieldInstance, Meta, ValidationError } from './base';

let context: Context;
let data: FieldInstance[];
Expand Down Expand Up @@ -34,10 +34,11 @@ describe('#addError()', () => {

describe('for type single', () => {
it('pushes an error with default error message', () => {
context.addError({ type: 'single', value: 'foo', meta });
context.addError({ type: 'field', value: 'foo', meta });

expect(context.errors).toHaveLength(1);
expect(context.errors).toContainEqual({
type: 'field',
value: 'foo',
msg: 'Invalid value',
path: 'bar',
Expand All @@ -47,10 +48,11 @@ describe('#addError()', () => {

it('pushes an error with context message', () => {
context = new ContextBuilder().setMessage('context message').build();
context.addError({ type: 'single', value: 'foo', meta });
context.addError({ type: 'field', value: 'foo', meta });

expect(context.errors).toHaveLength(1);
expect(context.errors).toContainEqual({
type: 'field',
value: 'foo',
msg: 'context message',
path: 'bar',
Expand All @@ -59,10 +61,11 @@ describe('#addError()', () => {
});

it('pushes an error with argument message', () => {
context.addError({ type: 'single', message: 'oh noes', value: 'foo', meta });
context.addError({ type: 'field', message: 'oh noes', value: 'foo', meta });

expect(context.errors).toHaveLength(1);
expect(context.errors).toContainEqual({
type: 'field',
value: 'foo',
msg: 'oh noes',
path: 'bar',
Expand All @@ -72,11 +75,12 @@ describe('#addError()', () => {

it('pushes an error with the message function return ', () => {
const message = jest.fn(() => 123);
context.addError({ type: 'single', message, value: 'foo', meta });
context.addError({ type: 'field', message, value: 'foo', meta });

expect(message).toHaveBeenCalledWith('foo', meta);
expect(context.errors).toHaveLength(1);
expect(context.errors).toContainEqual({
type: 'field',
value: 'foo',
msg: 123,
path: 'bar',
Expand All @@ -87,28 +91,32 @@ describe('#addError()', () => {

describe('for type nested', () => {
const req = {};
const nestedError: ValidationError = {
const nestedError: FieldValidationError = {
type: 'field',
value: 'foo',
path: 'bar',
location: 'body',
msg: 'Oh no',
};

it('pushes an error for the _error param with nested errors', () => {
it('pushes a request error with nested errors', () => {
context.addError({
type: 'nested',
type: 'alternative',
req,
nestedErrors: [nestedError],
});

expect(context.errors).toHaveLength(1);
expect(context.errors[0].path).toBe('_error');
expect(context.errors[0].nestedErrors).toEqual([nestedError]);
expect(context.errors).toContainEqual({
type: 'alternative',
msg: 'Invalid value',
nestedErrors: [nestedError],
});
});

it('pushes an error with default error message', () => {
context.addError({
type: 'nested',
type: 'alternative',
req,
nestedErrors: [nestedError],
});
Expand All @@ -119,7 +127,7 @@ describe('#addError()', () => {

it('pushes an error with argument message', () => {
context.addError({
type: 'nested',
type: 'alternative',
req,
message: 'oh noes',
nestedErrors: [nestedError],
Expand All @@ -132,7 +140,7 @@ describe('#addError()', () => {
it('pushes an error with the message function return', () => {
const message = jest.fn(() => 123);
context.addError({
type: 'nested',
type: 'alternative',
req,
message,
nestedErrors: [nestedError],
Expand Down
22 changes: 15 additions & 7 deletions src/context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import * as _ from 'lodash';
import { FieldInstance, Location, Meta, Request, ValidationError } from './base';
import {
FieldInstance,
FieldValidationError,
Location,
Meta,
Request,
ValidationError,
} from './base';
import { ContextItem } from './context-items';

function getDataMapKey(path: string, location: Location) {
Expand All @@ -25,16 +32,16 @@ export type Optional =

type AddErrorOptions =
| {
type: 'single';
type: 'field';
message?: any;
value: any;
meta: Meta;
}
| {
type: 'nested';
type: 'alternative';
req: Request;
message?: any;
nestedErrors: ValidationError[];
nestedErrors: FieldValidationError[];
};
export class Context {
private readonly _errors: ValidationError[] = [];
Expand Down Expand Up @@ -104,19 +111,20 @@ export class Context {
const msg = opts.message || this.message || 'Invalid value';
let error: ValidationError;
switch (opts.type) {
case 'single':
case 'field':
error = {
type: 'field',
value: opts.value,
msg: typeof msg === 'function' ? msg(opts.value, opts.meta) : msg,
path: opts.meta?.path,
location: opts.meta?.location,
};
break;

case 'nested':
case 'alternative':
error = {
type: 'alternative',
msg: typeof msg === 'function' ? msg(opts.req) : msg,
path: '_error',
nestedErrors: opts.nestedErrors,
};
break;
Expand Down
Loading

0 comments on commit 439220d

Please sign in to comment.