Skip to content

Commit

Permalink
feat: add type inference for defu result (#24)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielroe committed Oct 28, 2020
1 parent 40a00cc commit 934d736
Show file tree
Hide file tree
Showing 5 changed files with 129 additions and 17 deletions.
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -21,6 +21,7 @@
"@types/jest": "latest",
"@types/node": "latest",
"eslint": "latest",
"expect-type": "^0.8.0",
"jest": "latest",
"siroc": "^0.4.0",
"standard-version": "latest",
Expand Down
12 changes: 2 additions & 10 deletions src/defu.ts
@@ -1,12 +1,4 @@

type Merger = (obj: any, key: string, value: any) => any
type DefuFn = <T>(...args: T | any) => T
interface Defu {
<T>(...args: T | any): T
fn: DefuFn
arrayFn: DefuFn
extend(merger?: Merger): DefuFn
}
import type { Merger, DefuFn, Defu } from './types'

function isObject (val: any) {
return val !== null && typeof val === 'object'
Expand Down Expand Up @@ -49,7 +41,7 @@ function _defu<T> (baseObj: T, defaults: any, merger?: Merger): T {

// Create defu wrapper with optional merger and multi arg support
function extend (merger?: Merger): DefuFn {
return (...args) => args.reduce((p, c) => _defu(p, c, merger), {})
return (...args) => args.reduce((p, c) => _defu(p, c, merger), {} as any)
}

// Basic version
Expand Down
80 changes: 80 additions & 0 deletions src/types.ts
@@ -0,0 +1,80 @@
export type Merger = <T extends Input, K extends keyof T>(
obj: T,
key: keyof T,
value: T[K]
) => any;

export type DefuFn = <Source extends Input, Defaults extends Input>(
source: Source,
...defaults: Defaults[]
) => MergeObjects<Source, Defaults>;

export interface Defu {
<Source extends Input, Defaults extends Input>(source: Source, ...defaults: Defaults[]): MergeObjects<
Source,
Defaults
>;
fn: DefuFn;
arrayFn: DefuFn;
extend(merger?: Merger): DefuFn;
}

type Input = Record<string | number | symbol, any>

type MergeArrays<Destination, Source> = Destination extends Array<infer DestinationType>
? Source extends Array<infer SourceType>
? Array<DestinationType | SourceType>
: Source | Array<DestinationType>
: Source | Destination

type MergeObjects<
Destination extends Input,
Defaults extends Input
> = Omit<Destination, keyof Destination & keyof Defaults> & Omit<Defaults, keyof Destination & keyof Defaults> &
{
-readonly [Key in keyof Destination & keyof Defaults]:
Destination[Key] extends null
? Defaults[Key] extends null
? null
: Defaults[Key]
: Defaults[Key] extends null
? Destination[Key]
: Merge<Destination[Key], Defaults[Key]>
}

export type Merge<
Destination extends Input,
Defaults extends Input
> =
// Remove explicitly null types
Destination extends null
? Defaults extends null
? null
: Defaults
: Defaults extends null
? Destination
// Handle arrays
: Destination extends Array<any>
? Defaults extends Array<any>
? MergeArrays<Destination, Defaults>
: Destination | Defaults
// Don't attempt to merge Functions, RegExps, Promises
: Destination extends Function
? Destination | Defaults
: Destination extends RegExp
? Destination | Defaults
: Destination extends Promise<any>
? Destination | Defaults
// Don't attempt to merge Functions, RegExps, Promises
: Defaults extends Function
? Destination | Defaults
: Defaults extends RegExp
? Destination | Defaults
: Defaults extends Promise<any>
? Destination | Defaults
// Ensure we only merge Records
: Destination extends Input
? Defaults extends Input
? MergeObjects<Destination, Defaults>
: Destination | Defaults
: Destination | Defaults
48 changes: 41 additions & 7 deletions test/defu.test.ts
@@ -1,3 +1,4 @@
import { expectTypeOf } from 'expect-type'
import defu from '../src/defu'

// Part of tests brought from jonschlinkert/defaults-deep (MIT)
Expand All @@ -6,44 +7,76 @@ const nonObject = [null, undefined, [], false, true, 123]

describe('defu', () => {
it('should copy only missing properties defaults', () => {
expect(defu({ a: 'c' }, { a: 'bbb', d: 'c' })).toEqual({ a: 'c', d: 'c' })
const result = defu({ a: 'c' }, { a: 'bbb', d: 'c' })
expect(result).toEqual({ a: 'c', d: 'c' })
expectTypeOf(result).toEqualTypeOf<{ a: string, d: string }>()
})

it('should fill in values that are null', () => {
expect(defu({ a: null }, { a: 'c', d: 'c' })).toEqual({ a: 'c', d: 'c' })
expect(defu({ a: 'c' }, { a: null, d: 'c' })).toEqual({ a: 'c', d: 'c' })
const result1 = defu({ a: null as null }, { a: 'c', d: 'c' })
expect(result1).toEqual({ a: 'c', d: 'c' })
expectTypeOf(result1).toEqualTypeOf<{ a: string, d: string }>()

const result2 = defu({ a: 'c' }, { a: null as null, d: 'c' })
expect(result2).toEqual({ a: 'c', d: 'c' })
expectTypeOf(result2).toEqualTypeOf<{ a: string, d: string }>()
})

it('should copy nested values', () => {
expect(defu({ a: { b: 'c' } }, { a: { d: 'e' } })).toEqual({
const result = defu({ a: { b: 'c' } }, { a: { d: 'e' } })
expect(result).toEqual({
a: { b: 'c', d: 'e' },
})
expectTypeOf(result).toEqualTypeOf<{ a: { b: string, d: string } }>()
})

it('should concat array values by default', () => {
expect(defu({ array: ['b', 'c'] }, { array: ['a'] })).toEqual({
const result = defu({ array: ['b', 'c'] }, { array: ['a'] })
expect(result).toEqual({
array: ['a', 'b', 'c'],
})
expectTypeOf(result).toEqualTypeOf<{ array: string[] }>()
})

it('should correctly type differing array values', () => {
const item1 = { name: 'Name', age: 21 }
const item2 = { name: 'Name', age: '42' }
const result = defu({ items: [item1] }, { items: [item2] })
expect(result).toEqual({ items: [item2, item1] })
expectTypeOf(result).toEqualTypeOf<{ items: Array<{ name: string, age: string } | { name: string, age: number }> }>()
})

it('should correctly merge different object types', () => {
const fn = () => 42
const re = /test/i

const result = defu({ a: fn }, { a: re })
expect(result).toEqual({ a: fn })
expectTypeOf(result).toEqualTypeOf<{ a: (() => number) | RegExp }>()
})

it('should handle non object first param', () => {
for (const val of nonObject) {
// @ts-expect-error
expect(defu(val, { d: true })).toEqual({ d: true })
}
})

it('should handle non object second param', () => {
for (const val of nonObject) {
// @ts-expect-error
expect(defu({ d: true }, val)).toEqual({ d: true })
}
})

it('multi defaults', () => {
expect(defu({ a: 1 }, { b: 2, a: 'x' }, { c: 3, a: 'x', b: 'x' })).toEqual({
const result = defu({ a: 1 }, { b: 2, a: 'x' }, { c: 3, a: 'x', b: 'x' })
expect(result).toEqual({
a: 1,
b: 2,
c: 3,
})
expectTypeOf(result).toEqualTypeOf<{ a: string | number, b: string | number, c?: number }>()
})

it('should not override Object prototype', () => {
Expand All @@ -58,6 +91,7 @@ describe('defu', () => {
})

it('should ignore non-object arguments', () => {
// @ts-expect-error
expect(defu(null, { foo: 1 }, false, 123, { bar: 2 })).toEqual({
foo: 1,
bar: 2,
Expand All @@ -67,7 +101,7 @@ describe('defu', () => {
it('custom merger', () => {
const ext = defu.extend((obj, key, val) => {
if (typeof val === 'number') {
obj[key] += val
;(obj as any)[key] += val
return true
}
})
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Expand Up @@ -2232,6 +2232,11 @@ expand-brackets@^2.1.4:
snapdragon "^0.8.1"
to-regex "^3.0.1"

expect-type@^0.8.0:
version "0.8.0"
resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-0.8.0.tgz#dd82ab508e7b3eae083072d6197551cb18a82551"
integrity sha512-JJqodW5GNh8tDfupFEwlR9CGLWlI6JrS1G/bktgS2xFVZCLwIfgqstODtmoZ8/pSeqzSDSWLCYrqZy130NwyHQ==

expect@^26.1.0:
version "26.1.0"
resolved "https://registry.yarnpkg.com/expect/-/expect-26.1.0.tgz#8c62e31d0f8d5a8ebb186ee81473d15dd2fbf7c8"
Expand Down

0 comments on commit 934d736

Please sign in to comment.