diff --git a/README.md b/README.md index e29a585..3afea73 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ A validation library for TypeScript needs to be: - Common validators are available - Lightweight - Dependency free - - Tiny bundle size (~ 10Kb) + - Tiny bundle size - Fully tree shakeable `reviewed` exposes a flexible interface that achieves these goals. @@ -245,6 +245,30 @@ if (guard(isNumber)(input)) { } ``` +### Optional fields + +Validators can be made optional: + +```ts +interface Person { + name?: string; +} + +const isPerson = isRecordOf({ name: optional(isString) }); + +isPerson({}) >> + { + valid: true, + parsed: { name: "Joel" }, + }; + +isPerson({ name: "Joel" }) >> + { + valid: true, + parsed: { name: "Joel" }, + }; +``` + ### Using literals Array literals can be converted directly to validators: @@ -327,6 +351,8 @@ const not: (validator: Validator, reason?: string) => Validator; const both: (first: Validator, second: Validator) => Validator; const either: (first: Validator, second: Validator) => Validator; +const optional = (validator: Validator): Validator; + const isArrayOf: (validator: Validator) => Validator; const isRecordOf: (validators: ValidatorFields) => Validator; ``` diff --git a/src/factories/transform.spec.ts b/src/factories/transform.spec.ts index 5bd4793..161a5a7 100644 --- a/src/factories/transform.spec.ts +++ b/src/factories/transform.spec.ts @@ -1,4 +1,5 @@ -import { both, either, not } from "./transform"; +import { both, either, not, optional } from "./transform"; +import { isNaturalNumberString } from "../validators/strings"; import { isNonEmptyArray, isStringArray } from "../validators/arrays"; import { isNull, isString } from "../validators/primitives"; @@ -52,3 +53,15 @@ describe("either", () => { expect(isStringOrNull(1).error).toBe("Not a string: 1"); }); }); + +describe("optional", () => { + it("allows a validator to accept undefined inputs", () => { + expect(optional(isNaturalNumberString)(1).valid).toBe(false); + + expect(optional(isNaturalNumberString)("1").valid).toBe(true); + expect(optional(isNaturalNumberString)("1").parsed).toBe(1); + + expect(optional(isNaturalNumberString)(undefined).valid).toBe(true); + expect(optional(isNaturalNumberString)(undefined).parsed).toBe(undefined); + }); +}); diff --git a/src/factories/transform.ts b/src/factories/transform.ts index 06674c4..5096f30 100644 --- a/src/factories/transform.ts +++ b/src/factories/transform.ts @@ -1,5 +1,6 @@ import { Validated, Validator } from "../models/validators"; import { invalidateWith } from "./invalidate"; +import { isUndefined } from "../validators/primitives"; import { validate } from "./validate"; /** @@ -114,3 +115,32 @@ export const either = return right.valid ? right : left; }; + +/** + * Allow a validator to accept undefined inputs + * + * @category Factories + * @example + * interface Person { + * name?: string; + * } + * + * const isPerson = isRecordOf({ name: optional(isString) }); + * + * isPerson({}) >> + * { + * valid: true, + * parsed: { name: undefined }, + * }; + * + * isPerson({ name: "Joel" }) >> + * { + * valid: true, + * parsed: { name: "Joel" }, + * }; + * + * @typeParam T - The validated type + * @param first - The validator + */ +export const optional = (validator: Validator): Validator => + either(validator, isUndefined); diff --git a/src/factories/validate.spec.ts b/src/factories/validate.spec.ts index 2ed95cd..c66591c 100644 --- a/src/factories/validate.spec.ts +++ b/src/factories/validate.spec.ts @@ -1,4 +1,6 @@ +import { isNaturalNumberString } from "../validators/strings"; import { isNumber, isString } from "../validators/primitives"; +import { optional } from "./transform"; import { validate, validateWith, validateWithAtLeast } from "./validate"; describe("validate", () => { @@ -62,6 +64,22 @@ describe("validateWith", () => { error: "Unexpected extra fields: c", }); }); + + it("parses optional fields", () => { + const validator = validateWith<{ a: string; b?: number }>({ + a: isString, + b: optional(isNaturalNumberString), + }); + + expect(validator({ a: "1" }).valid).toBe(true); + expect(validator({ a: "1" }).parsed).toEqual({ a: "1", b: undefined }); + + expect(validator({ a: "1", b: undefined }).valid).toBe(true); + expect(validator({ a: "1", b: undefined }).parsed).toEqual({ a: "1", b: undefined }); + + expect(validator({ a: "1", b: 1 }).error).toEqual({ b: "Not a string: 1" }); + expect(validator({ a: "1", b: "1" }).parsed).toEqual({ a: "1", b: 1 }); + }); }); describe("validateWithAtLeast", () => { @@ -115,4 +133,20 @@ describe("validateWithAtLeast", () => { error: null, }); }); + + it("parses optional fields", () => { + const validator = validateWithAtLeast<{ a: string; b?: number }>({ + a: isString, + b: optional(isNaturalNumberString), + }); + + expect(validator({ a: "1" }).valid).toBe(true); + expect(validator({ a: "1" }).parsed).toEqual({ a: "1", b: undefined }); + + expect(validator({ a: "1", b: undefined }).valid).toBe(true); + expect(validator({ a: "1", b: undefined }).parsed).toEqual({ a: "1", b: undefined }); + + expect(validator({ a: "1", b: 1 }).error).toEqual({ b: "Not a string: 1" }); + expect(validator({ a: "1", b: "1" }).parsed).toEqual({ a: "1", b: 1 }); + }); }); diff --git a/src/factories/validate.ts b/src/factories/validate.ts index 2a99939..6ce369c 100644 --- a/src/factories/validate.ts +++ b/src/factories/validate.ts @@ -63,7 +63,7 @@ export const validateWith = } const missing = Object.keys(validators).filter( - (i) => !(i in record.parsed), + (i) => !(i in record.parsed || validators[i as keyof T](undefined).valid), ); if (missing.length > 0) { @@ -111,7 +111,7 @@ export const validateWithAtLeast = } const missing = Object.keys(validators).filter( - (i) => !(i in record.parsed), + (i) => !(i in record.parsed || validators[i as keyof T](undefined).valid), ); if (missing.length > 0) {