Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add castComparator function #34

Merged
merged 12 commits into from
Jul 14, 2024
56 changes: 56 additions & 0 deletions docs/casted/castComparator.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
title: castComparator
description: Cast a value into a comparator function
---

### Usage

Create a comparator function which can be passed into `Array.prototype.sort`. It accepts either a property name or a mapping function. Optionally, you can pass a custom compare function (e.g. for `localeCompare` use cases).

The first argument of `castComparator` is called the `mapping`. This can be either:

- **Function**: If `mapping` is a function, it maps the input values to a comparable value.
- **Property Name**: If `mapping` is a property name, it maps the input values to a property of the input values with a comparable value.

```ts
import * as _ from 'radash'

const users = [
{ id: 1, firstName: 'Alice', lastName: 'Smith' },
{ id: 3, firstName: 'Charlie', lastName: 'Brown' },
{ id: 2, firstName: 'Drew', lastName: 'Johnson' },
]

const compareById = _.castComparator('id')
users.sort(compareById)
// [Alice, Drew, Charlie]

const compareByFullName = _.castComparator(
user => `${user.firstName} ${user.lastName}`,
(a, b) => b.localeCompare(a),
)
users.sort(compareByFullName)
// [Alice, Charlie, Drew]
```

### Compare Function

Optionally, you can pass a custom `compare` function that receives the mapped values and returns a number. If not provided, values are compared with the `<` and `>` built-in operators.

A positive number means the “right value” is greater than the “left value”, a negative number means the “left value” is greater than the “right value”, and 0 means both values are equal.

```ts
const users = [
{ id: 1, firstName: 'Alice', lastName: 'Smith' },
{ id: 3, firstName: 'Charlie', lastName: 'Brown' },
{ id: 2, firstName: 'Drew', lastName: 'Johnson' },
]

const compareByFullName = _.castComparator(
user => `${user.firstName} ${user.lastName}`,
(a, b) => b.localeCompare(a),
)

users.sort(compareByFullName)
// [Alice, Charlie, Drew]
```
100 changes: 100 additions & 0 deletions src/casted/castComparator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import {
type Comparable,
type Comparator,
isFunction,
type MappedInput,
type MappedOutput,
type Mapping,
} from 'radashi'

/**
* Cast a value into a comparator function.
*
* - **Function**: If `mapping` is a function, it maps the input
* values to a comparable value.
* - **Property Name**: If `mapping` is a property name, it maps the
* input values to a property of the input values with a comparable
* value.
*
* Optionally, you can pass a custom `compare` function that receives
* the mapped values and returns a number. If not provided, values are
* compared with the `<` and `>` built-in operators. A positive number
* means the “right value” is greater than the “left value”, a
* negative number means the “left value” is greater than the “right
* value”, and 0 means both values are equal.
*
* @see https://radashi-org.github.io/reference/casted/castComparator
* @example
* ```ts
* const compareUserNames = castComparator(
* (user) => user.name,
* (a, b) => b.localeCompare(a),
* )
*
* const users = [
* { name: 'John', age: 20 },
* { name: 'Jane', age: 25 },
* { name: 'Doe', age: 22 },
* ]
*
* users.sort(compareUserNames)
* // => [Doe, Jane, John]
* ```
*/
// Support property name:
// castComparator('name')
export function castComparator<TMapping extends keyof any>(
mapping: TMapping,
): Comparator<MappedInput<TMapping, Comparable>>

// Support property name and compare fn:
// castComparator('name', (a: number, b: number) => {…})
export function castComparator<T, TMapping extends Mapping<any, T>>(
mapping: TMapping,
compare: Comparator<T>,
): Comparator<MappedInput<TMapping, T>>

// Support explicit function type:
// castComparator((data: TInput) => {…})
export function castComparator<TInput, TOutput = Comparable>(
mapping: (data: TInput) => TOutput,
compare?: Comparator<TOutput>,
): Comparator<TInput>

// Support explicit input type parameter:
// castComparator<TInput>(…)
export function castComparator<TInput>(
mapping: ComparatorMapping<TInput>,
): Comparator<TInput>

// Handle everything else with this signature.
export function castComparator<TMapping extends ComparatorMapping>(
mapping: TMapping,
compare?: Comparator<MappedOutput<TMapping>>,
): Comparator<MappedInput<TMapping>>

export function castComparator(
mapping: ComparatorMapping<any>,
compare?: Comparator<any>,
) {
const map = isFunction(mapping) ? mapping : (obj: any) => obj[mapping]
return (left: any, right: any) => {
const mappedLeft = map(left)
const mappedRight = map(right)
if (compare) {
return compare(mappedLeft, mappedRight)
}
return mappedLeft > mappedRight ? 1 : mappedLeft < mappedRight ? -1 : 0
}
}

/**
* A value that describes how a comparator maps the input values to a
* comparable value.
*
* @see https://radashi-org.github.io/reference/casted/castComparator
*/
export type ComparatorMapping<
T = any,
Compared extends Comparable = Comparable,
> = Mapping<T, Compared>
8 changes: 5 additions & 3 deletions src/casted/castMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,16 @@ export type OptionalMapping<T = any, U = any> = Mapping<T, U> | null | undefined
*
* @see https://radashi-org.github.io/reference/casted/castMapping
*/
export type MappedInput<TMapping> = TMapping extends (arg: infer Arg) => any
export type MappedInput<TMapping, TPropertyValue = any> = TMapping extends (
arg: infer Arg,
) => any
? [Arg] extends [Any]
? unknown
: Arg
: TMapping extends keyof any
?
| { [P in TMapping]: any }
| (TMapping extends number ? readonly any[] : never)
| { [P in TMapping]: TPropertyValue }
| (TMapping extends number ? readonly TPropertyValue[] : never)
: unknown

/**
Expand Down
1 change: 1 addition & 0 deletions src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export * from './async/tryit.ts'

export * from './casted/castArray.ts'
export * from './casted/castArrayIfExists.ts'
export * from './casted/castComparator.ts'
export * from './casted/castMapping.ts'

export * from './curry/callable.ts'
Expand Down
28 changes: 28 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,31 @@ export type BoxedPrimitive<T> = T extends string
? // biome-ignore lint/complexity/noBannedTypes:
Symbol
: T

/**
* A value that can be reliably compared with JavaScript comparison
* operators (e.g. `>`, `>=`, etc).
*/
export type Comparable =
| number
| string
| bigint
| { valueOf: () => number | string | bigint }
| { [Symbol.toPrimitive](hint: 'number'): number }
| { [Symbol.toPrimitive](hint: 'string'): string }

/**
* Extract a string union of property names from type `T` whose value
* can be compared with `>`, `>=`, etc.
*/
export type ComparableProperty<T> = CompatibleProperty<T, Comparable>

/**
* A comparator function. It can be passed to the `sort` method of
* arrays to sort the elements.
*
* Return a negative number to sort the “left” value before the “right”
* value, a positive number to sort the “right” value before the “left”
* value, and 0 to keep the order of the values.
*/
export type Comparator<T> = (left: T, right: T) => number
56 changes: 56 additions & 0 deletions tests/casted/castComparator.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Comparable, ComparableProperty, Comparator } from 'radashi'
import * as _ from 'radashi'

describe('castComparator', () => {
test('with property name only', () => {
expectTypeOf(_.castComparator('name')).toEqualTypeOf<
Comparator<{ name: Comparable }>
>()

expectTypeOf(_.castComparator('name')).toEqualTypeOf<
(left: { name: Comparable }, right: { name: Comparable }) => number
>()
})
test('with property name with explicit type parameter', () => {
expectTypeOf(_.castComparator<{ a: number }>('a')).toEqualTypeOf<
Comparator<{ a: number }>
>()

expectTypeOf(_.castComparator<{ a: number }>('a')).toEqualTypeOf<
(left: { a: number }, right: { a: number }) => number
>()
})
test('with property name with compare callback', () => {
expectTypeOf(
_.castComparator('a', (left: number, right: number) => left - right),
).toEqualTypeOf<Comparator<{ a: number }>>()
})
test('with generic property name', () => {
function test<
T,
TProperty extends ComparableProperty<T> = ComparableProperty<T>,
>(a: TProperty) {
const comparator = _.castComparator<T>(a)

expectTypeOf(comparator).toEqualTypeOf<Comparator<T>>()

return comparator
}

expectTypeOf(test<string>('length')).toEqualTypeOf<Comparator<string>>()
})
test('with mapping function', () => {
expectTypeOf(_.castComparator((a: number) => a)).toEqualTypeOf<
Comparator<number>
>()

expectTypeOf(_.castComparator((obj: { a: number }) => obj.a)).toEqualTypeOf<
Comparator<{ a: number }>
>()

// Explicit type parameter
expectTypeOf(_.castComparator<string>(str => str.length)).toEqualTypeOf<
Comparator<string>
>()
})
})
28 changes: 28 additions & 0 deletions tests/casted/castComparator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as _ from 'radashi'

describe('castComparator', () => {
test('accepts a property name', () => {
const compare = _.castComparator('a')
expect(compare({ a: 1 }, { a: 2 })).toBe(-1)
expect(compare({ a: 2 }, { a: 1 })).toBe(1)
expect(compare({ a: 1 }, { a: 1 })).toBe(0)
})
test('accepts a property name and a compare function', () => {
const ascending = vi.fn((a: number, b: number) => a - b)
const compare = _.castComparator('a', ascending)
expect(compare({ a: 1 }, { a: 2 })).toBe(-1)
expect(compare({ a: 2 }, { a: 1 })).toBe(1)
expect(ascending).toHaveBeenCalledTimes(2)
})
test('accepts a mapping function', () => {
const compare = _.castComparator((obj: { a: number }) => obj.a)
expect(compare({ a: 1 }, { a: 2 })).toBe(-1)
expect(compare({ a: 2 }, { a: 1 })).toBe(1)
})
test('accepts a mapping function and a compare function', () => {
const order = vi.fn((a: number, b: number) => a - b)
const compare = _.castComparator((obj: { a: number }) => obj.a, order)
expect(compare({ a: 1 }, { a: 2 })).toBe(-1)
expect(compare({ a: 2 }, { a: 1 })).toBe(1)
})
})
Loading