diff --git a/index.ts b/index.ts index 998a832..a16d0dd 100644 --- a/index.ts +++ b/index.ts @@ -62,6 +62,53 @@ export const coerce: Coerce = (...coercers: any[]) => }; //#endregion +//#region Type Guards +// ----------------------------------------------------------------------------- + +/** + * Type guard string + */ +export const isString = (value: unknown): value is string => + typeof value === 'string'; + +/** + * Type guard number + */ +export const isNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +/** + * Type guard bigint + */ +export const isBigInt = (value: unknown): value is bigint => + typeof value === 'bigint'; + +/** + * Type guard function + */ +export const isFunction = (value: unknown): value is Function => + typeof value === 'function'; + +/** + * Type guard object + */ +export const isObject = (value: unknown): value is object => + typeof value === 'object' && value !== null; + +/** + * Type guard array + */ +export const isArray = (value: unknown): value is Array => + Array.isArray(value); + +/** + * Type guard against `undefined` and `null` + */ +export const isDefined = (value: T): value is NonNullable => + typeof value !== 'undefined' && value !== null; + +//#endregion + //#region Validators // ----------------------------------------------------------------------------- @@ -107,6 +154,47 @@ export const negative = (value: number) => { throw new TypeError(`“${value}” is not a negative number.`); }; +/** + * Confirm value is defined + */ +export const defined = (value: T) => { + if (isDefined(value)) { + return value; + } + throw new TypeError(`Unexpected ${value}`); +}; + +/** + * Confirm value is an object + */ +export const object = (value: T) => { + if (isObject(value)) { + return value; + } + throw new TypeError(`${value} is not an object.`); +}; + +/** + * Confirm value is a function + */ +export const func = (value: T) => { + if (isFunction(value)) { + return value; + } + throw new TypeError(`${value} is not a function.`); +}; + +/** + * Confirm value is `instanceof` … + */ +export const instance = (type: new (...args: any[]) => T) => + (value: unknown) => { + if (value instanceof func(type)) { + return value; + } + throw new TypeError(`${value} is not an instance of ${type.name || type}.`); + }; + /** * Confirm the `value` is within `list` (a.k.a. Enum) */ @@ -131,17 +219,17 @@ export const within = (list: T[]) * Coerce value to primitive `string` */ export const string = (value: string | number | bigint) => { - if (typeof value === 'string') { + if (isString(value)) { return value; } - if ((typeof value === 'number' && Number.isFinite(value)) || typeof value === 'bigint') { + if ((isNumber(value) && Number.isFinite(value)) || isBigInt(value)) { return value.toString(); } throw new TypeError(`Unable to parse “${value}” as a string.`); }; export const nonstring = (value: T) => { - if (typeof value === 'string') { + if (isString(value)) { throw new TypeError(`${value} is a string.`); } return value as Exclude; @@ -152,8 +240,8 @@ export const nonString = nonstring; * Coerce value to `number` */ export const number = (value: string | number | bigint): number => { - if (Number.isFinite(value)) { - return value as number; + if (isNumber(value)) { + return value; } // remove everything except characters allowed in a number return number(Number(nonempty(string(value).replace(/[^0-9oex.-]/g, '')))); @@ -163,9 +251,9 @@ export const number = (value: string | number | bigint): number => { * Coerce value to a valid `Date` */ export const date = (value: number | string | Date) => { - const object = new Date(value); - nonZero(object.valueOf()); - return object; + const dateObject = new Date(value); + nonZero(dateObject.valueOf()); + return dateObject; }; /** @@ -212,10 +300,13 @@ export const boolean: CoerceBoolean = * Confirm `value` is Iterable */ export const iterable = (value: Iterable | T) => { - if (typeof value === 'object' && value && typeof value[Symbol.iterator] === 'function') { + try { + object(value); + func(value[Symbol.iterator]); return value as Iterable; + } catch { + throw new Error(`${value} is not iterable`); } - throw new Error(`${value} is not iterable`); }; /** @@ -437,13 +528,13 @@ export const limit = (max: number) => * `array` */ (value: T) => { - if (typeof value === 'number') { + if (isNumber(value)) { return Math.min(value, max) as T; } - if (typeof value === 'string') { + if (isString(value)) { return value.slice(0, max) as T; } - if (Array.isArray(value)) { + if (isArray(value)) { return value.slice(0, max) as T; } throw new TypeError(`Unable to apply a max of ${max} to ${value}`); diff --git a/test/index.test.ts b/test/index.test.ts index 05fcaa8..ad10274 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,10 +1,14 @@ +/* eslint-disable max-classes-per-file */ /* eslint-disable no-eval */ import test, { Macro } from 'ava'; import { coerce, string, safe, nonempty, spaces, trim, quotes, proper, postalCodeUs5, + defined, + instance, boolean, array, + object, number, positive, negative, limit, split, within, email, phone, phone10, prettyPhone, integer, nonzero, date, } from '../index.js'; @@ -16,11 +20,17 @@ pass.title = (providedTitle = '', _command, input, expected) => `${providedTitle} ${input} = ${expected} (${typeof expected})`.trim(); const fail: Macro = (t, command, input) => { - t.throws(() => { command(eval(input)); }, { instanceOf: Error }); + t.throws(() => { command(eval(input)); }, { instanceOf: TypeError }); }; fail.title = (providedTitle = '', _command, input) => `${providedTitle} ${input} TypeError`.trim(); +const passInstance: Macro = (t, command, input, expected) => { + t.true(command(eval(input)) instanceof expected); +}; +passInstance.title = (providedTitle = '', _command, input, expected) => + `${providedTitle} ${input} instanceOf ${expected}`.trim(); + // string test(pass, coerce(string), "'1'", '1'); test(pass, coerce(string), '1', '1'); @@ -62,6 +72,10 @@ test(pass, coerce(postalCodeUs5), "'07417-1111'", '07417'); test(fail, coerce(postalCodeUs5), "'0741'"); test(fail, coerce(postalCodeUs5), '10001'); // numbers not allowed because leading 0’s mess things up +// defined +test(pass, coerce(defined), "'I am defined'", 'I am defined'); +test(fail, coerce(defined), "('I am _not_ defined', undefined)", undefined); + // boolean const trueOrFalse = boolean(); Object.defineProperty(trueOrFalse, 'name', { value: 'boolean' }); @@ -110,6 +124,20 @@ test(pass, coerce(nonzero, integer), '1.2', 1); test(fail, coerce(nonzero, integer), '0'); test(fail, coerce(number, nonzero), ''); +// object +test(pass, coerce(object), '({is: "object"})', { is: 'object' }); +test(fail, coerce(object), '"not an object"'); + +// instance +class Nameless { + // @ts-ignore + static name = undefined; +} +test(passInstance, coerce(instance(Date)), 'new Date(1)', Date); +test(fail, coerce(instance(Error)), 'new Date(2)'); +test(fail, coerce(instance(Nameless)), 'new Date(3)'); +test(fail, coerce(instance({ foo: 'bar' } as unknown as new () => {})), 'new Date(4)'); + // limit const limit3 = limit(3); Object.defineProperty(limit3, 'name', { value: 'limit' });