diff --git a/src/object/crush.ts b/src/object/crush.ts index 7a6397f2..324dddcc 100644 --- a/src/object/crush.ts +++ b/src/object/crush.ts @@ -1,4 +1,4 @@ -import { get, keys, objectify } from 'radashi' +import { type Intersect, isArray, isObject, type Simplify } from 'radashi' /** * Flattens a deep object to a single dimension, converting the keys @@ -11,13 +11,54 @@ import { get, keys, objectify } from 'radashi' * // { name: 'ra', 'children.0.name': 'hathor' } * ``` */ -export function crush(value: TValue): object { +export function crush(value: T): Crush { if (!value) { - return {} + return {} as Crush } - return objectify( - keys(value), - k => k, - k => get(value, k), - ) + return (function crushReducer( + crushed: Crush, + value: unknown, + path: string, + ) { + if (isObject(value) || isArray(value)) { + for (const [prop, propValue] of Object.entries(value)) { + crushReducer(crushed, propValue, path ? `${path}.${prop}` : prop) + } + } else { + crushed[path as keyof Crush] = value as Crush[keyof Crush] + } + return crushed + })({} as Crush, value, '') } + +/** + * The return type of the `crush` function. + * + * This type is limited by TypeScript's development. There's no + * reliable way to detect if an object will pass `isObject` or not, so + * we cannot infer the property types of nested objects that have been + * crushed. + * + * @see https://radashi-org.github.io/reference/object/crush + */ +export type Crush = T extends readonly (infer U)[] + ? Record + : Simplify< + Intersect< + keyof T extends infer Prop + ? Prop extends keyof T + ? T[Prop] extends infer Value + ? + | ([Extract] extends [never] + ? never + : Record) + | ([Exclude] extends [never] + ? never + : [Extract] extends [never] + ? { [P in Prop]: Value } + : Record) + : never + : never + : never + > + > diff --git a/src/types.ts b/src/types.ts index 5234cb95..20baf2c4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -108,3 +108,19 @@ export type ComparableProperty = CompatibleProperty * value, and 0 to keep the order of the values. */ export type Comparator = (left: T, right: T) => number + +/** Convert a union to an intersection */ +export type Intersect = (U extends any ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never + +/** + * Useful to flatten the type output to improve type hints shown in + * editors. And also to transform an interface into a type to aide + * with assignability. + * + * @see https://github.com/microsoft/TypeScript/issues/15300 + */ +export type Simplify = {} & { [P in keyof T]: T[P] } diff --git a/tests/object/crush.test-d.ts b/tests/object/crush.test-d.ts new file mode 100644 index 00000000..059db22c --- /dev/null +++ b/tests/object/crush.test-d.ts @@ -0,0 +1,53 @@ +import * as _ from 'radashi' + +describe('crush', () => { + test('direct properties are preserved in the result type', () => { + const obj = _.crush({ a: 1, b: '2', c: true }) + expectTypeOf(obj).toEqualTypeOf<{ a: number; b: string; c: boolean }>() + }) + test('nested objects add Record to result type', () => { + const obj = _.crush({ a: { b: 1 } }) + expectTypeOf(obj).toEqualTypeOf>() + + const obj2 = _.crush({ a: 1, b: { c: 2 } }) + expectTypeOf(obj2).toEqualTypeOf<{ a: number; [key: string]: unknown }>() + expectTypeOf(obj2.a).toEqualTypeOf() + expectTypeOf(obj2['b.c']).toEqualTypeOf() + }) + test('optional property', () => { + const obj = _.crush({} as { a?: number }) + // FIXME: Try to preserve optionality. + expectTypeOf(obj).toEqualTypeOf<{ a: number | undefined }>() + }) + test('crushed Record type', () => { + const obj = _.crush({} as Record) + expectTypeOf(obj).toEqualTypeOf>() + + const obj2 = _.crush({} as Record) + expectTypeOf(obj2).toEqualTypeOf>() + + const obj3 = _.crush({} as Record) + expectTypeOf(obj3).toEqualTypeOf>() + + const obj4 = _.crush({} as Record) + expectTypeOf(obj4).toEqualTypeOf>() + }) + test('crushed array', () => { + // We cannot assume the keys from "number[]" input value, but we + // *can* assume the value type. Note that the value type doesn't + // contain "undefined" (which matches array behavior). + const obj = _.crush([1, 2, 3]) + expectTypeOf(obj).toEqualTypeOf>() + + const obj2 = _.crush([1, { b: 2 }]) + expectTypeOf(obj2).toEqualTypeOf>() + expectTypeOf(obj2[0]).toEqualTypeOf() + }) + test('union type with object and primitive', () => { + // Since "a" may be an object, we cannot assume the result will + // have an "a" property. Therefore, the keys and values of the + // result are unknown. + const obj = _.crush({ a: {} as number | object }) + expectTypeOf(obj).toEqualTypeOf>() + }) +}) diff --git a/tests/object/crush.test.ts b/tests/object/crush.test.ts index 01ed141c..3645a8d6 100644 --- a/tests/object/crush.test.ts +++ b/tests/object/crush.test.ts @@ -33,4 +33,67 @@ describe('crush', () => { timestamp: now, }) }) + test('handles arrays', () => { + const obj = ['value', 123.4, true] + expect(_.crush(obj)).toEqual({ + '0': 'value', + '1': 123.4, + '2': true, + }) + }) + describe('property names containing period', () => { + test('inside the root object', () => { + const obj = { + 'a.b': { c: 'value' }, + } + expect(_.crush(obj)).toEqual({ + 'a.b.c': 'value', + }) + }) + test('inside a nested object', () => { + const obj = { + a: { 'b.c': 'value' }, + } + expect(_.crush(obj)).toEqual({ + 'a.b.c': 'value', + }) + }) + test('inside both root and nested object', () => { + const obj = { + 'a.b': { 'c.d': 123.4 }, + } + expect(_.crush(obj)).toEqual({ + 'a.b.c.d': 123.4, + }) + }) + test('crush array containing object with nested property', () => { + const arr = [{ 'c.d': { 'e.f': 'g' } }] + const obj = { + 'a.b': arr, + } + expect(_.crush(obj)).toEqual({ + 'a.b.0.c.d.e.f': 'g', + }) + }) + test('do not crush Date objects', () => { + const date = new Date() + const obj = { + 'a.b': date, + } + expect(_.crush(obj)).toEqual({ + 'a.b': date, + }) + }) + test('do not crush Map objects', () => { + const map = new Map() + map.set('a', 'b') + map.set('c', 'd') + const obj = { + 'a.b': map, + } + expect(_.crush(obj)).toEqual({ + 'a.b': map, + }) + }) + }) })