Skip to content

Commit

Permalink
refactor to allow for chaining
Browse files Browse the repository at this point in the history
  • Loading branch information
adamchal committed Aug 16, 2021
1 parent 90fc6d7 commit e8a8479
Show file tree
Hide file tree
Showing 9 changed files with 353 additions and 299 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"arrayify",
"chainable",
"codecov",
"nonstring",
"o'donnel"
]
}
77 changes: 46 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Coerce

Coerce inputs to chainable types, formatters, sanitizers, and validators.
Coerce input to types and formats with sanitizers and validators.

## Features

Expand All @@ -17,50 +17,65 @@ npm i @resolute/coerce
## Usage

```js
import coerce, { string, trim, nonEmpty } from '@resolute/coerce';
```
trim a string and confirm it is not empty, `.or()` return undefined
```js
coerce(' foo ').to(string, trim, nonEmpty).or(undefined); // 'foo'
coerce(' ').to(string, trim, nonEmpty).or(undefined); // undefined
import coerce, { string, safe, spaces, trim, nonEmpty } from '@resolute/coerce';
// sanitize input: removing dangerous characters, normalize double/half/utf
// spaces, trim whitespace, and confirm result is non-empty
const sanitize = coerce(string, safe, spaces, trim, nonEmpty);
```

`.or()` throw an error
```js
coerce(' ').to(string, trim, nonEmpty).or(Error);
// Uncaught Error: Unable to parse “undefined” as a string.
```

`.or()` throw a specific error
```js
coerce(' ').to(string, trim, nonEmpty).or(new Error('Unable to parse input.'));
// Uncaught Error: Unable to parse input.
```
* failures **throw** a coerce TypeError
```js
sanitize(' foo '); // 'foo'
sanitize(' '); // Uncaught TypeError
```
* failures **return** default value (never throws)
```js
sanitize(' ', undefined); // undefined
```
* failures **throw** error instance
```js
sanitize(' ', new Error('Oh no!')); // Uncaught Error: Oh no!
```
* failures **throw** error factory
```js
class CustomError extends Error { }
const errorFactory = (error: Error) => new CustomError(error.message);
sanitize(' ', errorFactory); // Uncaught CustomError
```

## Examples

Confirm a string value is within a list (enum).
Confirm a string value is within a list (enum)
```js
import coerce, { within } from '@resolute/coerce';
const list = ['foo', 'bar']; // any iterable type ok to use
const inList = coerce(within(['foo', 'bar'])); // any iterable type ok to use
try {
const item = coerce(input).to(within(list)).or(Error);
inList(input);
// input is either 'foo' or 'bar'
} catch (error) {
// input was not 'foo' or 'bar'
}
```

Convert any iterable (except strings) to an array. Non-iterables return an array
of length=1 containing the non-iterable.
Convert anything to an array
```js
import coerce, { array } from '@resolute/coerce';
const arrayify = (input) => coerce(input).to(array).or(Error);
arrayify(new Map([[1, 1], [2, 2]]); // [[1, 1], [2, 2]]
arrayify(new Set([1, 2, 3])); // [1, 2, 3]
arrayify([1, 2, 3]); // [1, 2, 3] (no change)
arrayify(1); // [1]
arrayify('123'); // ['123'] (NOT ['1', '2', '3'] even though Strings are iterable)
arrayify(Buffer.from('123')); // [49, 50, 51] // Buffer char codes
arrayify(null); // [null]
const arrayify = coerce(array);
```
* Iterables (except strings) → `Array`
```js
arrayify(new Map([[1, 1], [2, 2]]); // [[1, 1], [2, 2]]
arrayify(new Set([1, 2, 3])); // [1, 2, 3]
arrayify(Buffer.from('123')); // [49, 50, 51] (char codes)
// even though Strings are iterable, they are NOT broken apart
arrayify('123'); // ['123']
```
* Non-iterables (including strings) → `[item]` (wrapped in array)
```js
arrayify(1); // [1]
arrayify(null); // [null]
```
* Arrays → `Array` (no change)
```js
arrayify([1, 2, 3]); // [1, 2, 3]
```
88 changes: 49 additions & 39 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,67 @@
import type { To } from './types.js';
import type { Coerce } from './types.js';

/**
* Pipe/flow/chain the input and output of each function
* 1. throw Error; or
* 2. throw error function factory; or
* 3. return the `otherwise` value
*/
const pipe = <S extends (value: any) => any>(
value: unknown,
functions: S[],
index: number,
): ReturnType<S> => {
if (index === functions.length) {
return value as ReturnType<S>;
}
const newValue = functions[index](value);
return pipe(newValue, functions, index + 1);
};

/**
* Throw Error or throw user-passed function or return a default value
*/
const handleFailure = <U>(otherwise: U, error: Error) => {
const failure = <U>(error: Error, otherwise: U) => {
if (typeof otherwise === 'function') {
throw otherwise(error);
}
if (typeof otherwise === 'object' && otherwise instanceof Error) {
throw otherwise;
}
return otherwise as Exclude<U, Function | Error>;
return otherwise;
};

/**
* Coerce a value `.to()` specified type `.or()` on failure return a default
* value or throw an `Error`.
* Pipe the input through coerce functions
*/
const pipe = <V, C extends (value: any) => any>(value: V, coercer: C) =>
coercer(value);

/**
* Handles issues where passing otherwise: undefined triggers the default
* TypeError value. This workaround determines if the default otherwise:
* TypeError should be used based on the argument count passed to the function.
* This is instead of simply using a default parameter value, which would not
* work in the case where undefined is passed.
*/
export const coerce = <I>(value: I): To<I> => ({
/**
* `.to()` one or many sanitizer functions
*/
to: (...sanitizers: Array<(value: any) => any>) => ({
/**
* `.or()` return a default value, throw a specific `Error`, or throws a the
* `Error` returned from a function.
*/
or: <U>(otherwise: U) => {
try {
return pipe(value, sanitizers, 0);
} catch (error) {
return handleFailure(otherwise, error);
}
},
}),
});
const params = (args: unknown[]) => {
if (args.length === 1) {
return [args[0] as unknown, TypeError] as const;
}
return args;
};

/**
* Coerce a value
* `coerce(...coercers)(value[, default])`
*
* @example
* // trim a string and confirm it is not empty
* const trimCheckEmpty = coerce(string, trim, nonEmpty);
*
* trimCheckEmpty(' foo '); // 'foo'
* trimCheckEmpty(' '); // Error
*
* // alternatively, return undefined instead of throwing error
* trimCheckEmpty(' ', undefined); // undefined
*
*/
export const coerce: Coerce = (...coercers: any[]) =>
(...args: unknown[]) => {
const [value, otherwise] = params(args);
try {
return coercers.reduce(pipe, value);
} catch (error) {
return failure(error, otherwise);
}
};

export default coerce;
export * from './primitive.js';
export * from './validator.js';
export * from './mutator.js';
export type { Coerce, Coercer } from './types.js';
12 changes: 6 additions & 6 deletions mutator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { nonEmpty } from './validator.js';
import { nonempty } from './validator.js';

/**
* Remove dangerous characters from string
Expand Down Expand Up @@ -129,7 +129,7 @@ export const proper = (value: string) =>
* Format email addresses
*/
export const email = (value: string) =>
nonEmpty(value.toLowerCase().replace(/\s+/g, ''));
nonempty(value.toLowerCase().replace(/\s+/g, ''));

/**
* Strip all non-digit characters from string
Expand All @@ -142,7 +142,7 @@ export const digits = (value: string) => value.replace(/[^\d]/g, '');
export const phone = (value: string) => {
const onlyDigits = digits(value).replace(/^[01]+/, '');
if (onlyDigits.length < 10) {
throw new Error('Invalid US phone number.');
throw new TypeError('Invalid US phone number.');
}
return onlyDigits;
};
Expand All @@ -153,7 +153,7 @@ export const phone = (value: string) => {
export const phone10 = (value: string) => {
const valid = phone(value);
if (valid.length !== 10) {
throw new Error('Invalid US 10-digit phone number.');
throw new TypeError('Invalid US 10-digit phone number.');
}
return valid;
};
Expand All @@ -175,7 +175,7 @@ export const prettyPhone = (value: string) => {
export const postalCodeUs5 = (value: string) => {
const code = digits(value).slice(0, 5);
if (code.length !== 5) {
throw new Error('Invalid US postal code');
throw new TypeError('Invalid US postal code');
}
return code;
};
Expand Down Expand Up @@ -204,7 +204,7 @@ export const limit = (max: number) =>
if (Array.isArray(value)) {
return value.slice(0, max) as T;
}
throw new Error(`Unable to apply a max of ${max} to ${value}`);
throw new TypeError(`Unable to apply a max of ${max} to ${value}`);
};

/**
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@resolute/coerce",
"version": "0.0.0",
"description": "Coerce inputs to chainable types, formatters, sanitizers, and validators.",
"description": "Coerce input to types and formats with sanitizers and validators.",
"type": "module",
"main": "./",
"types": "./",
Expand Down
56 changes: 37 additions & 19 deletions primitive.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { nonEmpty, nonZero } from './validator.js';
import { coerce } from './index.js';
import { nonempty, nonZero } from './validator.js';

/**
* Coerce value to primitive `string`
Expand All @@ -10,9 +11,17 @@ export const string = (value: string | number | bigint) => {
if ((typeof value === 'number' && Number.isFinite(value)) || typeof value === 'bigint') {
return value.toString();
}
throw new Error(`Unable to parse “${value}” as a string.`);
throw new TypeError(`Unable to parse “${value}” as a string.`);
};

export const nonstring = <T>(value: T) => {
if (typeof value === 'string') {
throw new TypeError(`${value} is a string.`);
}
return value as Exclude<T, string>;
};
export const notString = nonstring;

/**
* Coerce value to `number`
*/
Expand All @@ -21,7 +30,7 @@ export const number = (value: string | number | bigint): number => {
return value as number;
}
// remove everything except characters allowed in a number
return number(Number(nonEmpty(string(value).replace(/[^0-9oex.-]/g, ''))));
return number(Number(nonempty(string(value).replace(/[^0-9oex.-]/g, ''))));
};

/**
Expand All @@ -36,14 +45,14 @@ export const date = (value: number | string | Date) => {
/**
* Boolean
*/
interface Boolean {
interface CoerceBoolean {
(): (value: unknown) => true | false;
<T>(truthy: T): (value: unknown) => T | false;
<T, F>(truthy: T, falsey: F): (value: unknown) => T | F;
<T, F, N>(truthy: T, falsey: F, nully: N): (value: unknown) => T | F | N;
<T, F, N, U>(truthy: T, falsey: F, nully: N, undefy: U): (value: unknown) => T | F | N | U;
}
export const boolean: Boolean =
export const boolean: CoerceBoolean =
(truthy: any = true, falsy: any = false, nully: any = falsy, undefy: any = falsy) =>
(value: unknown) => {
switch (typeof value) {
Expand Down Expand Up @@ -73,24 +82,33 @@ export const boolean: Boolean =
return value ? truthy : falsy;
};

const isIterable = <T>(value: Iterable<T> | any): value is Iterable<T> => {
/**
* Confirm `value` is Iterable
*/
export const iterable = <T>(value: Iterable<T> | T) => {
// export const iterable:CoerceIterable =
// <T, U extends Exclude<any, Iterable<any>>>(value: Iterable<T> | U) => {
if (typeof value === 'object' && value && typeof value[Symbol.iterator] === 'function') {
return true;
// if (isIterable(value)) {
return value as Iterable<T>;
}
return false;
throw new Error(`${value} is not iterable`);
};

/**
* `value` as an array if not an array
*/
export const array = <T>(value: T | T[] | Iterable<T>) => {
if (Array.isArray(value)) {
return value;
}
// a `string` _is_ Iterable, but we do not want to return an array of
// characters
if (typeof value !== 'string' && isIterable(value)) {
return [...value];
* `value` as an array if not an array
*/
interface CoerceArray {
<T>(input: Iterable<T>): T[];
<T>(input: T): T[];
}
export const array: CoerceArray = <T, U>(value: Iterable<T> | U) => {
try {
// a `string` _is_ Iterable, but we do not want to return an array of
// characters
const iterableValue = coerce(nonstring, iterable)(value);
return [...iterableValue];
} catch {
return [value] as U[];
}
return [value];
};
Loading

0 comments on commit e8a8479

Please sign in to comment.