Skip to content

Commit

Permalink
feat: add NativeEnum validator (#54)
Browse files Browse the repository at this point in the history
  • Loading branch information
vladfrangu authored Mar 5, 2022
1 parent e6827c5 commit 7359042
Show file tree
Hide file tree
Showing 9 changed files with 272 additions and 8 deletions.
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ export * from './lib/errors/CombinedPropertyError';
export * from './lib/errors/ConstraintError';
export * from './lib/errors/ExpectedValidationError';
export * from './lib/errors/MissingPropertyError';
export * from './lib/errors/UnknownEnumValueError';
export * from './lib/errors/UnknownPropertyError';
export * from './lib/errors/ValidationError';

export * from './lib/Result';
export * from './type-exports';
5 changes: 5 additions & 0 deletions src/lib/Shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
TupleValidator,
UnionValidator
} from '../validators/imports';
import { NativeEnumLike, NativeEnumValidator } from '../validators/NativeEnumValidator';
import type { Constructor, MappedObjectValidator } from './util-types';

export class Shapes {
Expand Down Expand Up @@ -73,6 +74,10 @@ export class Shapes {
return this.union(...values.map((value) => this.literal(value)));
}

public nativeEnum<T extends NativeEnumLike>(enumShape: T): NativeEnumValidator<T> {
return new NativeEnumValidator(enumShape);
}

public literal<T>(value: T): BaseValidator<T> {
if (value instanceof Date) return this.date.eq(value) as unknown as BaseValidator<T>;
return new LiteralValidator(value);
Expand Down
48 changes: 48 additions & 0 deletions src/lib/errors/UnknownEnumValueError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { InspectOptionsStylized } from 'node:util';
import { BaseError, customInspectSymbolStackLess } from './BaseError';

export class UnknownEnumValueError extends BaseError {
public readonly value: string | number;
public readonly enumKeys: string[];
public readonly enumMappings: Map<string | number, string | number>;

public constructor(value: string | number, keys: string[], enumMappings: Map<string | number, string | number>) {
super('Expected the value to be one of the following enum values:');

this.value = value;
this.enumKeys = keys;
this.enumMappings = enumMappings;
}

public toJSON() {
return {
name: this.name,
value: this.value,
enumKeys: this.enumKeys,
enumMappings: [...this.enumMappings.entries()]
};
}

protected [customInspectSymbolStackLess](depth: number, options: InspectOptionsStylized): string {
const value = options.stylize(this.value.toString(), 'string');
if (depth < 0) {
return options.stylize(`[UnknownEnumValueError: ${value}]`, 'special');
}

const padding = `\n ${options.stylize('|', 'undefined')} `;
const pairs = this.enumKeys
.map((key) => {
const enumValue = this.enumMappings.get(key)!;
return `${options.stylize(key, 'string')} or ${options.stylize(
enumValue.toString(),
typeof enumValue === 'number' ? 'number' : 'string'
)}`;
})
.join(padding);

const header = `${options.stylize('UnknownEnumValueError', 'special')} > ${value}`;
const message = options.stylize(this.message, 'regexp');
const pairsBlock = `${padding}${pairs}`;
return `${header}\n ${message}\n${pairsBlock}`;
}
}
3 changes: 2 additions & 1 deletion src/type-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export type { CombinedError } from './lib/errors/CombinedError';
export type { ConstraintError, ConstraintErrorNames } from './lib/errors/ConstraintError';
export type { ExpectedValidationError } from './lib/errors/ExpectedValidationError';
export type { MissingPropertyError } from './lib/errors/MissingPropertyError';
export type { UnknownEnumValueError } from './lib/errors/UnknownEnumValueError';
export type { UnknownPropertyError } from './lib/errors/UnknownPropertyError';
export type { ValidationError } from './lib/errors/ValidationError';
//
Expand All @@ -62,7 +63,7 @@ export type { Shapes, Unwrap } from './lib/Shapes';
export type { Constructor, MappedObjectValidator, NonNullObject, Type } from './lib/util-types';
//
export type { ArrayValidator } from './validators/ArrayValidator';
export type { BaseValidator } from './validators/BaseValidator';
export type { BaseValidator, ValidatorError } from './validators/BaseValidator';
export type { BigIntValidator } from './validators/BigIntValidator';
export type { BooleanValidator } from './validators/BooleanValidator';
export type { DateValidator } from './validators/DateValidator';
Expand Down
7 changes: 5 additions & 2 deletions src/validators/BaseValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import type { IConstraint } from '../constraints/base/IConstraint';
import type { BaseError } from '../lib/errors/BaseError';
import type { CombinedError } from '../lib/errors/CombinedError';
import type { CombinedPropertyError } from '../lib/errors/CombinedPropertyError';
import type { UnknownEnumValueError } from '../lib/errors/UnknownEnumValueError';
import type { ValidationError } from '../lib/errors/ValidationError';
import { Result } from '../lib/Result';
import { ArrayValidator, LiteralValidator, NullishValidator, SetValidator, UnionValidator, DefaultValidator } from './imports';
import { ArrayValidator, DefaultValidator, LiteralValidator, NullishValidator, SetValidator, UnionValidator } from './imports';

export abstract class BaseValidator<T> {
protected constraints: readonly IConstraint<T>[] = [];
Expand Down Expand Up @@ -67,11 +68,13 @@ export abstract class BaseValidator<T> {
return Reflect.construct(this.constructor, [this.constraints]);
}

protected abstract handle(value: unknown): Result<T, ValidationError | CombinedError | CombinedPropertyError>;
protected abstract handle(value: unknown): Result<T, ValidatorError>;

protected addConstraint(constraint: IConstraint<T>): this {
const clone = this.clone();
clone.constraints = clone.constraints.concat(constraint);
return clone;
}
}

export type ValidatorError = ValidationError | CombinedError | CombinedPropertyError | UnknownEnumValueError;
6 changes: 2 additions & 4 deletions src/validators/DefaultValidator.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import type { IConstraint } from '../constraints/base/IConstraint';
import type { CombinedError } from '../lib/errors/CombinedError';
import type { CombinedPropertyError } from '../lib/errors/CombinedPropertyError';
import type { ValidationError } from '../lib/errors/ValidationError';
import { Result } from '../lib/Result';
import type { ValidatorError } from './BaseValidator';
import { BaseValidator } from './imports';
import { getValue } from './util/getValue';

Expand All @@ -22,7 +20,7 @@ export class DefaultValidator<T> extends BaseValidator<T> {
return clone;
}

protected handle(value: unknown): Result<T, ValidationError | CombinedError | CombinedPropertyError> {
protected handle(value: unknown): Result<T, ValidatorError> {
return typeof value === 'undefined' //
? Result.ok(getValue(this.defaultValue))
: this.validator['handle'](value); // eslint-disable-line @typescript-eslint/dot-notation
Expand Down
63 changes: 63 additions & 0 deletions src/validators/NativeEnumValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { UnknownEnumValueError } from '../lib/errors/UnknownEnumValueError';
import { ValidationError } from '../lib/errors/ValidationError';
import { Result } from '../lib/Result';
import { BaseValidator } from './imports';

export class NativeEnumValidator<T extends NativeEnumLike> extends BaseValidator<T[keyof T]> {
public readonly enumShape: T;
public readonly hasNumericElements: boolean = false;
private readonly enumKeys: string[];
private readonly enumMapping = new Map<string | number, T[keyof T]>();

public constructor(enumShape: T) {
super();
this.enumShape = enumShape;

this.enumKeys = Object.keys(enumShape).filter((key) => {
return typeof enumShape[enumShape[key]] !== 'number';
});

for (const key of this.enumKeys) {
const enumValue = enumShape[key] as T[keyof T];

this.enumMapping.set(key, enumValue);
this.enumMapping.set(enumValue, enumValue);

if (typeof enumValue === 'number') {
this.hasNumericElements = true;
this.enumMapping.set(`${enumValue}`, enumValue);
}
}
}

protected override handle(value: unknown): Result<T[keyof T], ValidationError | UnknownEnumValueError> {
const typeOfValue = typeof value;

// Step 1. Possible numeric enum
if (typeOfValue === 'number' && !this.hasNumericElements) {
return Result.err(new ValidationError('s.nativeEnum(T)', 'Expected the value to be a string', value));
}

// Ensure type is string or number
if (typeOfValue !== 'string' && typeOfValue !== 'number') {
return Result.err(new ValidationError('s.nativeEnum(T)', 'Expected the value to be a string or number', value));
}

const casted = value as string | number;

const possibleEnumValue = this.enumMapping.get(casted);

return typeof possibleEnumValue === 'undefined'
? Result.err(new UnknownEnumValueError(casted, this.enumKeys, this.enumMapping))
: Result.ok(possibleEnumValue);
}

protected clone(): this {
return Reflect.construct(this.constructor, [this.enumShape]);
}
}

export interface NativeEnumLike {
[key: string]: string | number;
[key: number]: string;
}
63 changes: 63 additions & 0 deletions tests/lib/errors/UnknownEnumValueError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { inspect } from 'node:util';
import { UnknownEnumValueError } from '../../../src';

describe('UnknownEnumValueError', () => {
const error = new UnknownEnumValueError(
'foo',
['bar', 'baz'],
new Map<string | number, string | number>([
['bar', 1],
['baz', 'boo']
])
);

test('GIVEN an instance THEN assigns fields correctly', () => {
expect(error.message).toBe('Expected the value to be one of the following enum values:');
expect(error.value).toBe('foo');
expect(error.enumKeys).toEqual(['bar', 'baz']);
expect(error.enumMappings).toStrictEqual(
new Map<string | number, string | number>([
['bar', 1],
['baz', 'boo']
])
);
});

describe('inspect', () => {
test('GIVEN an inspected instance THEN formats data correctly', () => {
const content = inspect(error, { colors: false });
const expected = [
'UnknownEnumValueError > foo', //
' Expected the value to be one of the following enum values:',
'',
' | bar or 1',
' | baz or boo'
];

expect(content.startsWith(expected.join('\n'))).toBe(true);
});

test('GIVEN an inspected instance with negative depth THEN formats name only', () => {
const content = inspect(error, { colors: false, depth: -1 });
const expected = [
'[UnknownEnumValueError: foo]' //
];

expect(content.startsWith(expected.join('\n'))).toBe(true);
});
});

describe('toJSON', () => {
test('toJSON should return an object with name and property', () => {
expect(error.toJSON()).toEqual({
name: 'Error',
value: 'foo',
enumKeys: ['bar', 'baz'],
enumMappings: [
['bar', 1],
['baz', 'boo']
]
});
});
});
});
83 changes: 83 additions & 0 deletions tests/validators/nativeEnum.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { s, UnknownEnumValueError, ValidationError } from '../../src';
import { expectClonedValidator, expectError } from '../common/macros/comparators';

describe('NativeEnumValidator', () => {
describe('invalid inputs', () => {
const predicate = s.nativeEnum({ hello: 'world' });

test.each([true, null, undefined, {}])('GIVEN %p THEN throws ValidationError', (value) => {
expectError(() => predicate.parse(value), new ValidationError('s.nativeEnum(T)', 'Expected the value to be a string or number', value));
});
});

describe('string enum', () => {
enum StringEnum {
Hi = 'hi'
}

const stringPredicate = s.nativeEnum(StringEnum);

test.each([
['Hi', StringEnum.Hi],
[StringEnum.Hi, StringEnum.Hi]
])('GIVEN a key or value of a native enum (%p) THEN returns the value', (value, expected) => {
expect<StringEnum>(stringPredicate.parse(value)).toBe(expected);
});

it('GIVEN a number input for a string enum THEN throws ValidationError', () => {
expectError(() => stringPredicate.parse(1), new ValidationError('s.nativeEnum(T)', 'Expected the value to be a string', 1));
});
});

describe('number enum', () => {
enum NumberEnum {
Vladdy,
Kyra,
Favna
}
const numberPredicate = s.nativeEnum(NumberEnum);

test.each([
['Vladdy', NumberEnum.Vladdy],
[NumberEnum.Vladdy, NumberEnum.Vladdy]
])('GIVEN a key or value of a native enum (%p) THEN returns the value', (input, expected) => {
expect<NumberEnum>(numberPredicate.parse(input)).toBe(expected);
});
});

describe('mixed enum', () => {
enum MixedEnum {
Sapphire = 'is awesome',
Vladdy = 420
}

const mixedPredicate = s.nativeEnum(MixedEnum);

test.each([
['Sapphire', MixedEnum.Sapphire],
[MixedEnum.Sapphire, MixedEnum.Sapphire],
['Vladdy', MixedEnum.Vladdy],
[MixedEnum.Vladdy, MixedEnum.Vladdy]
])('GIVEN a key or value of a native enum (%p) THEN returns the value', (input, expected) => {
expect<MixedEnum>(mixedPredicate.parse(input)).toBe(expected);
});
});

describe('valid input but invalid enum value', () => {
const predicate = s.nativeEnum({ owo: 42 });

test.each(['uwu', 69])('GIVEN valid type for input but not part of enum (%p) THEN throws ValidationError', (value) => {
expectError(() => predicate.parse(value), new UnknownEnumValueError(value, ['owo'], new Map([['owo', 42]])));
});
});

test('GIVEN clone THEN returns similar instance', () => {
const predicate = s.nativeEnum({ Example: 69 });
// @ts-expect-error Test clone
const clonePredicate = predicate.clone();

expectClonedValidator(predicate, clonePredicate);
expect(predicate.parse('Example')).toBe(69);
expect(predicate.parse(69)).toBe(69);
});
});

0 comments on commit 7359042

Please sign in to comment.