Skip to content

Commit

Permalink
feat: accept a fallback comparator and improve method names for compa…
Browse files Browse the repository at this point in the history
…ratorWithPredicate
  • Loading branch information
JanMalch committed Jun 15, 2021
1 parent 5930386 commit db7b4ba
Show file tree
Hide file tree
Showing 2 changed files with 93 additions and 29 deletions.
63 changes: 54 additions & 9 deletions src/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,27 +76,28 @@ export function composeComparators<T>(

/**
* A factory that holds a list of comparators and attached predicates.
* Use the `add` method to push additional comparators with predicates, thus broadening the range of accepted values and types.
* Use the `orIf` method to push additional comparators with predicates, thus broadening the range of accepted values and types.
*
* `toComparator()` will produce the final `Comparator`, that will only accept values which match one of the added predicates.
* `orElse` and `orElseThrow` will produce the final `Comparator`, that will only accept values which match one of the added predicates.
* Predicates will be checked in the same order as they were added.
*
* **On each comparator call both arguments must match the same predicate.**
* If no such predicate exists, the comparator will throw an error.
* If no such predicate exists, the comparator will either throw an error or use a fallback comparator,
* depending on whether you used `orElseThrow` or `orElse` respectively.
*
* Use type guards as predicates to ensure the best type safety.
*
* @example
* // inferred type: Comparator<string | number>
* const unionComparator = comparatorWithPredicate(isString, ignoreCase)
* .add(isNumber, reversedOrder)
* .toComparator();
* .orIf(isNumber, reversedOrder)
* .orElseThrow();
*
* // set type explicitly when not using a proper type guard; otherwise any will be inferred
* // inferred type: Comparator<string | number | boolean | Person>
* const anotherUnionComparator = comparatorWithPredicate(isPerson, personComparator)
* .add<string | number | boolean>(isPrimitive, naturalOrder)
* .toComparator();
* .orIf<string | number | boolean>(isPrimitive, naturalOrder)
* .orElseThrow();
*
* // not provided by this library
* declare const isString = (value: any) => value is string;
Expand All @@ -110,18 +111,45 @@ export interface ComparatorWithPredicateFactory<T> {
* @param predicate a predicate that indicates if the following comparator can handle the value
* @param comparator a comparator for the incoming value
* @see ComparatorWithPredicateFactory
* @deprecated use the identical `orIf` method instead. `add` will be removed in a future release.
*/
add<U>(
predicate: ((value: any) => value is U) | ((value: U) => boolean),
comparator: Comparator<U>
): ComparatorWithPredicateFactory<T | U>;

/**
* Add another predicate and comparator.
* @param predicate a predicate that indicates if the following comparator can handle the value
* @param comparator a comparator for the incoming value
* @see ComparatorWithPredicateFactory
*/
orIf<U>(
predicate: ((value: any) => value is U) | ((value: U) => boolean),
comparator: Comparator<U>
): ComparatorWithPredicateFactory<T | U>;

/**
* Creates a single comparator that will only accept values,
* which both match one of the attached predicates.
* @see ComparatorWithPredicateFactory
* @deprecated use the identical `orElseThrow` method instead. `toComparator` will be removed in a future release.
*/
toComparator(): Comparator<T>;

/**
* Creates a single comparator that will only accept values,
* which both match one of the attached predicates.
* @see ComparatorWithPredicateFactory
*/
orElseThrow(): Comparator<T>;

/**
* Creates a single comparator that will only accept values,
* which both match one of the attached predicates.
* @see ComparatorWithPredicateFactory
*/
orElse<U>(fallbackComparator: Comparator<U>): Comparator<T | U>;
}

class ComparatorWithPredicateFactoryImpl<T> implements ComparatorWithPredicateFactory<T> {
Expand All @@ -130,6 +158,13 @@ class ComparatorWithPredicateFactoryImpl<T> implements ComparatorWithPredicateFa
add<U>(
predicate: ((value: any) => value is U) | ((value: U) => boolean),
comparator: Comparator<U>
): ComparatorWithPredicateFactory<T | U> {
return this.orIf(predicate, comparator);
}

orIf<U>(
predicate: ((value: any) => value is U) | ((value: U) => boolean),
comparator: Comparator<U>
): ComparatorWithPredicateFactory<T | U> {
return new ComparatorWithPredicateFactoryImpl<T | U>([
...(this.comparators as any),
Expand All @@ -138,12 +173,22 @@ class ComparatorWithPredicateFactoryImpl<T> implements ComparatorWithPredicateFa
}

toComparator(): Comparator<T> {
return (a, b) => {
return this.orElseThrow();
}

orElseThrow(): Comparator<T> {
return this.orElse((a, b) => {
throw new Error(`Unable to find comparator for types [${typeof a}, ${typeof b}]`);
});
}

orElse<U>(fallbackComparator: Comparator<U>): Comparator<T | U> {
return (a: any, b: any) => {
const fittingComparator = this.comparators.find(
([predicate]) => predicate(a) && predicate(b)
);
if (!fittingComparator) {
throw new Error(`Unable to find comparator for types [${typeof a}, ${typeof b}]`);
return fallbackComparator(a, b);
}
return fittingComparator[1](a, b);
};
Expand Down
59 changes: 39 additions & 20 deletions test/comparators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ describe('comparatorWithPredicate', () => {
const isPrimitive = (value: any): boolean => typeof value !== 'object';

it('should work with a single comparator', () => {
const stringComparator = comparatorWithPredicate(isString, ignoreCase).toComparator();
const stringComparator = comparatorWithPredicate(isString, ignoreCase).orElseThrow();
expect(stringComparator('A', 'a')).toBe(FIRST_SAME_AS_SECOND);
});

Expand All @@ -301,9 +301,9 @@ describe('comparatorWithPredicate', () => {
isPerson,
compareBy((foo) => foo.name, localeCompare)
)
.add(isString, ignoreCase)
.add(isNumber, reversedOrder)
.toComparator();
.orIf(isString, ignoreCase)
.orIf(isNumber, reversedOrder)
.orElseThrow();

expect(unionComparator(1, 2)).toBe(FIRST_AFTER_SECOND); // isNumber -> reversedOrder
expect(unionComparator('A', 'a')).toBe(FIRST_SAME_AS_SECOND); // isString -> ignoreCase
Expand All @@ -318,27 +318,46 @@ describe('comparatorWithPredicate', () => {
)
// As "isPrimitive" only returns "boolean", the comparator would accept "any" but you can narrow the types.
// Type Guards are preferred though.
.add<string | number>(isPrimitive, naturalOrder)
.toComparator();
.orIf<string | number>(isPrimitive, naturalOrder)
.orElseThrow();

expect(unionComparator(1, 2)).toBe(FIRST_BEFORE_SECOND); // isPrimitive -> naturalOrder
expect(unionComparator('A', 'a')).toBe(FIRST_BEFORE_SECOND); // isPrimitive -> naturalOrder
expect(unionComparator({ name: 'John' }, { name: 'Frannie' })).toBe(FIRST_AFTER_SECOND); // isPerson -> compare by names
});

it("should throw an error if the values don't match the type of the comparator", () => {
const stringComparator = comparatorWithPredicate(
isString,
ignoreCase
).toComparator() as Comparator<any>;
expect(() => stringComparator(1, 2)).toThrow();
});

it("should throw an error if the two passed values don't have the same type", () => {
// Comparator<string | number>
const unionComparator = comparatorWithPredicate(isString, ignoreCase)
.add(isNumber, naturalOrder)
.toComparator();
expect(() => unionComparator(1, '2')).toThrow();
describe('with orElseThrow', () => {
it("should throw an error if the values don't match the type of the comparator", () => {
const stringComparator = comparatorWithPredicate(
isString,
ignoreCase
).orElseThrow() as Comparator<any>;
expect(() => stringComparator(1, 2)).toThrow();
});

it("should throw an error if the two passed values don't have the same type", () => {
// Comparator<string | number>
const unionComparator = comparatorWithPredicate(isString, ignoreCase)
.orIf(isNumber, naturalOrder)
.orElseThrow();
expect(() => unionComparator(1, '2')).toThrow();
});
});

describe('with orElse', () => {
it("should use the fallback comparator if the values don't match the type of the comparator", () => {
const stringComparator = comparatorWithPredicate(isString, ignoreCase).orElse(
naturalOrder
) as Comparator<any>;
expect(stringComparator(1, 2)).toBe(FIRST_BEFORE_SECOND);
});

it("should throw an error if the two passed values don't have the same type", () => {
// Comparator<string | number>
const unionComparator = comparatorWithPredicate(isString, ignoreCase)
.orIf(isNumber, naturalOrder)
.orElse(naturalOrder);
expect(unionComparator(1, 2)).toBe(FIRST_BEFORE_SECOND);
});
});
});

0 comments on commit db7b4ba

Please sign in to comment.