Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 5 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ const parse = (input: unknown) => {
I want to validate an object and get failure messages for each field:

```ts
import { errors, isNaturalNumberString } from "reviewed";
import { errors, isRecordOf, isNaturalNumberString } from "reviewed";

const paginate = (url: URL): void => {
const isPagination = validateWith({
const isPagination = isRecordOf({
page: isNaturalNumberString,
size: isNaturalNumberString,
});
Expand All @@ -43,6 +43,7 @@ const paginate = (url: URL): void => {
});

if (valid) {
// Parsed type: { page: number, size: number }
console.log(parsed);
} else {
console.error(error);
Expand Down Expand Up @@ -184,15 +185,15 @@ Custom Jest matchers are exposed for testing validators:

```json
{
"setupFilesAfterEnv": ["reviewed/dist/testing/jest.js"]
"setupFilesAfterEnv": ["reviewed/dist/testing/globals.js"]
}
```

`tsconfig.json`

```json
{
"files": ["node_modules/reviewed/dist/testing/jest.d.ts"]
"files": ["node_modules/reviewed/dist/testing/globals.d.ts"]
}
```

Expand All @@ -209,20 +210,6 @@ describe("isNaturalNumberString", () => {
});
```

For convenience you can define whole suites at once:

```ts
import { isNaturalNumberString } from "./strings";
import { suite } from "reviewed";

suite(isNaturalNumberString, [{ input: "1", parsed: 1 }], {
"Not a string": [undefined, null, true, 1, [], {}],
"Not a number string": ["", "true", "a", "NaN", "Infinity"],
"Not an integer string": ["0.5"],
"Not a natural number string": ["0", "-1"],
});
```

### Combining validators

Validators can be chained to validate a payload:
Expand Down
17 changes: 14 additions & 3 deletions jest.config.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
{
"transform": {
"^.+\\.ts$": ["ts-jest", { "isolatedModules": true }]
"^.+\\.ts$": [
"ts-jest",
{
"isolatedModules": true,
"tsconfig": {
"sourceMap": true
}
}
]
},
"modulePathIgnorePatterns": ["dist"],
"setupFilesAfterEnv": ["<rootDir>/src/testing/jest.ts"],
"setupFilesAfterEnv": ["<rootDir>/src/testing/globals.ts"],
"collectCoverage": true,
"coveragePathIgnorePatterns": ["<rootDir>/src/testing"]
"coveragePathIgnorePatterns": [
"<rootDir>/src/internal",
"<rootDir>/src/testing"
]
}
15 changes: 0 additions & 15 deletions src/factories/aliases.spec.ts

This file was deleted.

26 changes: 0 additions & 26 deletions src/factories/aliases.ts

This file was deleted.

10 changes: 9 additions & 1 deletion src/factories/arrays.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Validated, Validator } from "../models/validators";
import { all } from "./results";
import { all } from "../services/results";
import { isArray } from "../validators/arrays";

/**
Expand Down Expand Up @@ -66,3 +66,11 @@ export const validateAll =

return all(array.parsed.map(validator));
};

/**
* Alias for validateAll
*
* @category Aliases
* @see {@link validateAll}
*/
export const isArrayOf = validateAll;
8 changes: 5 additions & 3 deletions src/factories/guards.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { isNumber } from "../validators/primitives";
describe("guard", () => {
it("constructs a guard from a validator", () => {
const isNumberGuard = guard(isNumber);

expect(isNumberGuard(1)).toBe(true);
});
});
Expand All @@ -13,8 +14,9 @@ describe("fromGuard", () => {
const guard = (input: unknown): input is number =>
typeof input === "number" && !isNaN(input);

const isNumber = fromGuard(guard, "Not a number");
expect(isNumber(1).parsed).toBe(1);
expect(isNumber("").error).toBe('Not a number: ""');
const validator = fromGuard(guard, "Not a number");

expect(validator).toValidate(1);
expect(validator).toInvalidateWith("", 'Not a number: ""');
});
});
65 changes: 65 additions & 0 deletions src/factories/records.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { isMapping, validateWith, validateWithAtLeast } from "./records";
import { isNumber } from "../validators/primitives";
import { optional } from "../services/transformers";

describe("validateWith", () => {
it("validates an input's fields with validators", () => {
const validator = validateWith({
a: isNumber,
});

expect(validator).toValidate({ a: 1 });
expect(validator).toInvalidateWith(null, "Not a record: null");
expect(validator).toInvalidateWith({}, "Missing required fields: a");
expect(validator).toInvalidateWith({ a: "1" }, { a: 'Not a number: "1"' });
expect(validator).toInvalidateWith(
{ a: 1, b: 2 },
"Unexpected extra fields: b",
);
});

it("parses optional fields", () => {
const validator = validateWith({
a: optional(isNumber),
});

expect(validator).toValidate({ a: undefined });
expect(validator).toValidate({});
});
});

describe("validateWithAtLeast", () => {
it("validates an input's fields with validators", () => {
const validator = validateWithAtLeast({
a: isNumber,
});

expect(validator).toValidate({ a: 1 });
expect(validator).toInvalidateWith(null, "Not a record: null");
expect(validator).toInvalidateWith({}, "Missing required fields: a");
expect(validator).toInvalidateWith({ a: "1" }, { a: 'Not a number: "1"' });
expect(validator).toValidate({ a: 1, b: 2 });
});

it("parses optional fields", () => {
const validator = validateWithAtLeast({
a: optional(isNumber),
});

expect(validator).toValidate({ a: undefined });
expect(validator).toValidate({});
});
});

describe("isMapping", () => {
const validator = isMapping(isNumber);

it("validates a record with string keys", () => {
expect(validator).toValidate({ a: 1, b: 2 });
expect(validator).toInvalidateWith(null, "Not a record: null");
expect(validator).toInvalidateWith(
{ a: 1, b: "2" },
{ b: 'Not a number: "2"' },
);
});
});
164 changes: 164 additions & 0 deletions src/factories/records.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { Legend, Mapping } from "../models/records";
import { Validated, Validator } from "../models/validators";
import { ValidatedFields, ValidatorFields } from "../models/fields";
import { guard } from "./guards";
import { invalidate } from "./invalidate";
import { isRecord } from "../validators/records";
import { isString } from "../validators/primitives";
import { isUndefined } from "../validators/primitives";
import { merge } from "../services/results";
import { validate } from "./validate";

/**
* Validate an input's fields with validators
*
* @category Factories
* @example
* validateWith({ a: isNumber, b: isString })({ a: 1, b: "2" }) >>
* {
* valid: true,
* input: { a: 1, b: "2" },
* };
*
* @typeParam T - The validated type
* @param validators - The validators to use
*/
export const validateWith =
<T>(validators: ValidatorFields<T>): Validator<T> =>
(input: unknown) => {
const record = isRecord(input);

if (!record.valid) {
return record as Validated<T>;
}

const missing = Object.keys(validators).filter(
(i) => !(i in record.parsed || validators[i as keyof T](undefined).valid),
);

if (missing.length > 0) {
return invalidate(
input,
`Missing required fields: ${missing.join(", ")}`,
);
}

const extra = Object.keys(record.parsed).filter((i) => !(i in validators));

if (extra.length > 0) {
return invalidate(input, `Unexpected extra fields: ${extra.join(", ")}`);
}

const validated = Object.entries(record.parsed).reduce<[string, unknown][]>(
(acc, [k, v]) =>
guard(isUndefined)(v)
? acc
: [...acc, [k, validators[k as keyof T](v)]],
[],
);

return merge(Object.fromEntries(validated) as ValidatedFields<T>);
};

/**
* Validate an input's fields with validators allowing extra fields
*
* @category Factories
* @example
* validateWithAtLeast({ a: isNumber, b: isString })({ a: 1, b: "2" }) >>
* {
* valid: true,
* input: { a: 1, b: "2" },
* };
*
* @typeParam T - The validated type
* @param validators - The validators to use
*/
export const validateWithAtLeast =
<T>(validators: ValidatorFields<T>): Validator<T> =>
(input: unknown) => {
const record = isRecord(input);

if (!record.valid) {
return record as Validated<T>;
}

const missing = Object.keys(validators).filter(
(i) => !(i in record.parsed || validators[i as keyof T](undefined).valid),
);

if (missing.length > 0) {
return invalidate(
input,
`Missing required fields: ${missing.join(", ")}`,
);
}

const validated = Object.entries(record.parsed).reduce<[string, unknown][]>(
(acc, [k, v]) =>
guard(isUndefined)(v)
? acc
: [
...acc,
[k, (k in validators ? validators[k as keyof T] : validate)(v)],
],
[],
);

return merge(Object.fromEntries(validated) as ValidatedFields<T>);
};

/**
* Alias for validateWith
*
* @category Aliases
* @see {@link validateWith}
*/
export const isRecordOf = validateWith;

/**
* Alias for validateWithAtLeast
*
* @category Aliases
* @see {@link validateWithAtLeast}
*/
export const isRecordOfAtLeast = validateWithAtLeast;

/**
* Validate a record with string keys
*
* @category Services
* @example
* isMapping(isNumber)({ a: 1 }) >>
* {
* valid: true,
* input: { a: 1 },
* };
*
* @typeParam T - The validated type
* @param validator - The validator to use
*/
export const isMapping =
<T>(validator: Validator<T>): Validator<Mapping<T>> =>
(input: unknown) => {
const record = isRecord(input);

if (!record.valid) {
return record as Validated<Mapping<T>>;
}

const validated = Object.entries(record.parsed).map(([k, v]) => [
k,
validator(v),
]);

return merge(Object.fromEntries(validated) as ValidatedFields<Mapping<T>>);
};

/**
* Alias for isMapping(isString)
*
* @category Aliases
* @see {@link isMapping}
*/
export const isLegend: Validator<Legend> = isMapping(isString);
Loading