Skip to content

Commit

Permalink
feat: defined, object, func, instance
Browse files Browse the repository at this point in the history
  • Loading branch information
adamchal committed Sep 29, 2021
1 parent 0778ffe commit cfab7d9
Show file tree
Hide file tree
Showing 2 changed files with 133 additions and 14 deletions.
117 changes: 104 additions & 13 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T>(value: unknown): value is Array<T> =>
Array.isArray(value);

/**
* Type guard against `undefined` and `null`
*/
export const isDefined = <T>(value: T): value is NonNullable<T> =>
typeof value !== 'undefined' && value !== null;

//#endregion

//#region Validators
// -----------------------------------------------------------------------------

Expand Down Expand Up @@ -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 = <T>(value: T) => {
if (isDefined(value)) {
return value;
}
throw new TypeError(`Unexpected ${value}`);
};

/**
* Confirm value is an object
*/
export const object = <T>(value: T) => {
if (isObject(value)) {
return value;
}
throw new TypeError(`${value} is not an object.`);
};

/**
* Confirm value is a function
*/
export const func = <T>(value: T) => {
if (isFunction(value)) {
return value;
}
throw new TypeError(`${value} is not a function.`);
};

/**
* Confirm value is `instanceof` …
*/
export const instance = <T extends {} = {}>(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)
*/
Expand All @@ -131,17 +219,17 @@ export const within = <T extends string | number | boolean | object>(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 = <T>(value: T) => {
if (typeof value === 'string') {
if (isString(value)) {
throw new TypeError(`${value} is a string.`);
}
return value as Exclude<T, string>;
Expand All @@ -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, ''))));
Expand All @@ -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;
};

/**
Expand Down Expand Up @@ -212,10 +300,13 @@ export const boolean: CoerceBoolean =
* Confirm `value` is Iterable
*/
export const iterable = <T>(value: Iterable<T> | T) => {
if (typeof value === 'object' && value && typeof value[Symbol.iterator] === 'function') {
try {
object(value);
func(value[Symbol.iterator]);
return value as Iterable<T>;
} catch {
throw new Error(`${value} is not iterable`);
}
throw new Error(`${value} is not iterable`);
};

/**
Expand Down Expand Up @@ -437,13 +528,13 @@ export const limit = (max: number) =>
* `array`
*/
<T extends number | string | unknown[]>(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}`);
Expand Down
30 changes: 29 additions & 1 deletion test/index.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,11 +20,17 @@ pass.title = (providedTitle = '', _command, input, expected) =>
`${providedTitle} ${input} = ${expected} (${typeof expected})`.trim();

const fail: Macro<any> = (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<any, any> = (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');
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -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' });
Expand Down

0 comments on commit cfab7d9

Please sign in to comment.