Skip to content

Commit

Permalink
feat: added ObjectValidator (#3)
Browse files Browse the repository at this point in the history
Co-authored-by: Vlad Frangu <kingdgrizzle@gmail.com>
Co-authored-by: Jeroen Claassens <support@favware.tech>
  • Loading branch information
3 people committed Jan 16, 2022
1 parent e267b94 commit abe7ead
Show file tree
Hide file tree
Showing 18 changed files with 472 additions and 122 deletions.
11 changes: 4 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,6 @@ dish.parse(['Iberian ham', 10, new Date()]);
```typescript
// Properties are required by default:
const animal = s.object({
// TODO
name: s.string,
age: s.number
});
Expand All @@ -241,13 +240,11 @@ You can add additional fields using either an object or an ObjectValidator, in t

```typescript
const pet = animal.extend({
// TODO
owner: s.string.nullish
});

const pet = animal.extend(
s.object({
// TODO
owner: s.string.nullish
})
);
Expand All @@ -266,10 +263,10 @@ const pkg = s.object({
dependencies: s.string.array
});

const justTheName = pkg.pick(['name']); // TODO
const justTheName = pkg.pick(['name']);
// s.object({ name: s.string });

const noDependencies = pkg.omit(['dependencies']); // TODO
const noDependencies = pkg.omit(['dependencies']);
// s.object({ name: s.string, description: s.string });
```

Expand Down Expand Up @@ -311,7 +308,7 @@ person.parse({
// => { name: 'Sapphire' }
```

##### `.strict` // TODO
##### `.strict`

You can disallow unknown keys with `.strict`. If the input includes any unknown keys, an error will be thrown.

Expand All @@ -327,7 +324,7 @@ person.parse({
// => throws ValidationError
```

##### `.ignore` // TODO
##### `.ignore`

You can use the `.ignore` getter to reset an object schema to the default behaviour (ignoring unrecognized keys).

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@
"clean": "node scripts/clean.mjs",
"typecheck": "tsc -p tsconfig.typecheck.json",
"sversion": "standard-version",
"prepublishOnly": "yarn build",
"prepublishOnly": "rollup-type-bundler",
"prepare": "husky install .github/husky"
},
"devDependencies": {
"@commitlint/cli": "^16.0.2",
"@commitlint/config-conventional": "^16.0.0",
"@favware/npm-deprecate": "^1.0.4",
"@favware/rollup-type-bundler": "^1.0.7",
"@sapphire/eslint-config": "^4.0.9",
"@sapphire/prettier-config": "^1.2.8",
"@sapphire/ts-config": "^3.1.7",
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { Shapes } from './lib/Shapes';

export const s = new Shapes();

export * from './lib/errors/ConstraintError';
export * from './lib/errors/ExpectedValidationError';
export * from './lib/errors/MissingPropertyError';
export * from './lib/errors/UnknownPropertyError';
export * from './lib/errors/ValidationError';

export * from './lib/Result';
export * from './type-exports';
7 changes: 6 additions & 1 deletion src/lib/Shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ import {
NeverValidator,
NullishValidator,
NumberValidator,
ObjectValidator,
PassthroughValidator,
SetValidator,
StringValidator,
UnionValidator
} from '../validators/imports';
import type { Constructor } from './util-types';
import type { Constructor, MappedObjectValidator } from './util-types';

export class Shapes {
public get string() {
Expand All @@ -37,6 +38,10 @@ export class Shapes {
return new DateValidator();
}

public object<T>(shape: MappedObjectValidator<T>) {
return new ObjectValidator(shape);
}

public get undefined() {
return this.literal(undefined);
}
Expand Down
16 changes: 16 additions & 0 deletions src/lib/errors/MissingPropertyError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export class MissingPropertyError extends Error {
public readonly property: PropertyKey;

public constructor(property: PropertyKey) {
super(`Expected property "${String(property)}" is missing`);

this.property = property;
}

public toJSON() {
return {
name: this.name,
property: this.property
};
}
}
19 changes: 19 additions & 0 deletions src/lib/errors/UnknownPropertyError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export class UnknownPropertyError extends Error {
public readonly property: PropertyKey;
public readonly value: unknown;

public constructor(property: PropertyKey, value: unknown) {
super('Unknown property received');

this.property = property;
this.value = value;
}

public toJSON() {
return {
name: this.name,
property: this.property,
value: this.value
};
}
}
13 changes: 9 additions & 4 deletions src/lib/util-types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
/**
* @internal
* This type is only used inside of the library and is not exported on the root level
*/
import type { BaseValidator } from '../validators/BaseValidator';

export type Constructor<T> = (new (...args: readonly any[]) => T) | (abstract new (...args: readonly any[]) => T);

export type Type<V> = V extends BaseValidator<infer T> ? T : never;

// eslint-disable-next-line @typescript-eslint/ban-types
export type NonNullObject = {} & object;

export type MappedObjectValidator<T> = { [key in keyof T]: BaseValidator<T[key]> };
8 changes: 8 additions & 0 deletions src/type-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,17 @@ export type {
numberSafeInt
} from './constraints/NumberConstraints';
export type { stringLengthEq, stringLengthGe, stringLengthGt, stringLengthLe, stringLengthLt, stringLengthNe } from './constraints/StringConstraints';
//
export type { ConstraintError, ConstraintErrorMessageBuilder } from './lib/errors/ConstraintError';
export type { ExpectedValidationError } from './lib/errors/ExpectedValidationError';
export type { MissingPropertyError } from './lib/errors/MissingPropertyError';
export type { UnknownPropertyError } from './lib/errors/UnknownPropertyError';
export type { ValidationError } from './lib/errors/ValidationError';
//
export type { Shapes } 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 { BigIntValidator } from './validators/BigIntValidator';
Expand All @@ -31,6 +38,7 @@ export type { LiteralValidator } from './validators/LiteralValidator';
export type { NeverValidator } from './validators/NeverValidator';
export type { NullishValidator } from './validators/NullishValidator';
export type { NumberValidator } from './validators/NumberValidator';
export type { ObjectValidator, ObjectValidatorStrategy } from './validators/ObjectValidator';
export type { PassthroughValidator } from './validators/PassthroughValidator';
export type { SetValidator } from './validators/SetValidator';
export type { StringValidator } from './validators/StringValidator';
Expand Down
20 changes: 6 additions & 14 deletions src/validators/ArrayValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ export class ArrayValidator<T> extends BaseValidator<T[]> {
this.validator = validator;
}

public run(values: unknown): Result<T[], Error> {
protected override clone(): this {
return Reflect.construct(this.constructor, [this.validator, this.constraints]);
}

protected handle(values: unknown): Result<T[], ValidationError | AggregateError> {
if (!Array.isArray(values)) {
return Result.err(new ValidationError('ArrayValidator', 'Expected an array', values));
}
Expand All @@ -27,18 +31,6 @@ export class ArrayValidator<T> extends BaseValidator<T[]> {

return errors.length === 0 //
? Result.ok(transformed)
: Result.err(new AggregateError(errors, 'Could not match any of the defined validators'));
}

public parse(value: unknown): T[] {
return this.run(value).unwrap();
}

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

protected handle(): Result<T[], ValidationError> {
throw new Error('Unreachable');
: Result.err(new AggregateError(errors, 'Failed to validate at least one entry'));
}
}
2 changes: 1 addition & 1 deletion src/validators/BaseValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export abstract class BaseValidator<T> {
return Reflect.construct(this.constructor, [this.constraints]);
}

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

protected addConstraint(constraint: IConstraint<T>): this {
const clone = this.clone();
Expand Down
146 changes: 146 additions & 0 deletions src/validators/ObjectValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import type { IConstraint } from '../constraints/base/IConstraint';
import { MissingPropertyError } from '../lib/errors/MissingPropertyError';
import { UnknownPropertyError } from '../lib/errors/UnknownPropertyError';
import { ValidationError } from '../lib/errors/ValidationError';
import { Result } from '../lib/Result';
import type { MappedObjectValidator, NonNullObject } from '../lib/util-types';
import { BaseValidator } from './BaseValidator';

export class ObjectValidator<T extends NonNullObject> extends BaseValidator<T> {
public readonly shape: MappedObjectValidator<T>;
public readonly strategy: ObjectValidatorStrategy;
private readonly keys: readonly (keyof T)[];
private readonly handleStrategy: (value: NonNullObject) => Result<T, AggregateError>;

public constructor(
shape: MappedObjectValidator<T>,
strategy: ObjectValidatorStrategy = ObjectValidatorStrategy.Ignore,
constraints: readonly IConstraint<T>[] = []
) {
super(constraints);
this.shape = shape;
this.keys = Object.keys(shape) as (keyof T)[];
this.strategy = strategy;

switch (this.strategy) {
case ObjectValidatorStrategy.Ignore:
this.handleStrategy = (value) => this.handleIgnoreStrategy(value);
break;
case ObjectValidatorStrategy.Strict: {
this.handleStrategy = (value) => this.handleStrictStrategy(value);
break;
}
}
}

public get strict(): ObjectValidator<{ [Key in keyof T]-?: T[Key] }> {
return Reflect.construct(this.constructor, [this.shape, ObjectValidatorStrategy.Strict, this.constraints]);
}

public get ignore(): this {
return Reflect.construct(this.constructor, [this.shape, ObjectValidatorStrategy.Ignore, this.constraints]);
}

public get partial(): ObjectValidator<{ [Key in keyof T]?: T[Key] }> {
const shape = Object.fromEntries(this.keys.map((key) => [key, this.shape[key].optional]));
return Reflect.construct(this.constructor, [shape, this.strategy, this.constraints]);
}

public extend<ET extends NonNullObject>(schema: ObjectValidator<ET> | MappedObjectValidator<ET>): ObjectValidator<T & ET> {
const shape = { ...this.shape, ...(schema instanceof ObjectValidator ? schema.shape : schema) };
return Reflect.construct(this.constructor, [shape, this.strategy, this.constraints]);
}

public pick<K extends keyof T>(keys: readonly K[]): ObjectValidator<{ [Key in keyof Pick<T, K>]: T[Key] }> {
const shape = Object.fromEntries(keys.filter((key) => this.keys.includes(key)).map((key) => [key, this.shape[key]]));
return Reflect.construct(this.constructor, [shape, this.strategy, this.constraints]);
}

public omit<K extends keyof T>(keys: readonly K[]): ObjectValidator<{ [Key in keyof Omit<T, K>]: T[Key] }> {
const shape = Object.fromEntries(this.keys.filter((key) => !keys.includes(key as any)).map((key) => [key, this.shape[key]]));
return Reflect.construct(this.constructor, [shape, this.strategy, this.constraints]);
}

protected override handle(value: unknown): Result<T, ValidationError | AggregateError> {
const typeOfValue = typeof value;
if (typeOfValue !== 'object') {
return Result.err(
new ValidationError('ObjectValidator', `Expected the value to be an object, but received ${typeOfValue} instead`, value)
);
}

if (value === null) {
return Result.err(new ValidationError('ObjectValidator', 'Expected the value to not be null', value));
}

return this.handleStrategy(value as NonNullObject);
}

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

private handleIgnoreStrategy(value: NonNullObject, errors: Error[] = []): Result<T, AggregateError> {
const entries = {} as T;
let i = this.keys.length;

while (i--) {
const key = this.keys[i];
const result = this.shape[key].run(value[key as keyof NonNullObject]);

if (result.isOk()) {
entries[key] = result.value;
} else {
const error = result.error!;
if (error instanceof ValidationError && error.given === undefined) {
errors.push(new MissingPropertyError(key));
} else {
errors.push(error);
}
}
}

return errors.length === 0 //
? Result.ok(entries)
: Result.err(new AggregateError(errors, 'Failed to match at least one of the properties'));
}

private handleStrictStrategy(value: NonNullObject): Result<T, AggregateError> {
const errors: Error[] = [];
const finalResult = {} as T;
const keysToIterateOver = [...new Set([...Object.keys(value), ...this.keys])].reverse();
let i = keysToIterateOver.length;

while (i--) {
const key = keysToIterateOver[i] as string;

if (Object.prototype.hasOwnProperty.call(this.shape, key)) {
const result = this.shape[key as keyof MappedObjectValidator<T>].run(value[key as keyof NonNullObject]);

if (result.isOk()) {
finalResult[key as keyof T] = result.value;
} else {
const error = result.error!;
if (error instanceof ValidationError && error.given === undefined) {
errors.push(new MissingPropertyError(key));
} else {
errors.push(error);
}
}

continue;
}

errors.push(new UnknownPropertyError(key, value[key as keyof NonNullObject]));
}

return errors.length === 0 //
? Result.ok(finalResult)
: Result.err(new AggregateError(errors, 'Failed to match at least one of the properties'));
}
}

export const enum ObjectValidatorStrategy {
Ignore,
Strict
}
Loading

0 comments on commit abe7ead

Please sign in to comment.