Skip to content

Commit

Permalink
feat: add Merge and ObjectPlus.Merge
Browse files Browse the repository at this point in the history
  • Loading branch information
unional committed Sep 13, 2023
1 parent 0771911 commit 69c5409
Show file tree
Hide file tree
Showing 9 changed files with 156 additions and 75 deletions.
5 changes: 5 additions & 0 deletions .changeset/red-plums-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"type-plus": minor
---

Add `Merge<A, B>` and `ObjectPlus.Merge<A, B>`
2 changes: 1 addition & 1 deletion type-plus/ts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export * from './functional/index.js'
export type { JSONArray, JSONObject, JSONPrimitive, JSONTypes } from './json.js'
export type { Abs, Add, Decrement, GreaterThan, Increment, Max, Multiply, Subtract } from './math/index.js'
export * as MathPlus from './math/math_plus.js'
export * from './merge.js'
export * from './mix_types/merge.js'
export type { AnyOrNeverType, IsAnyOrNever } from './mix_types/any_or_never_type.js'
export type * from './mix_types/box.js'
export type { IsNever, IsNotNever, Is_Never, NeverType, NotNeverType, Not_Never } from './never/never_type.js'
Expand Down
38 changes: 0 additions & 38 deletions type-plus/ts/merge.ts

This file was deleted.

78 changes: 48 additions & 30 deletions type-plus/ts/merge.spec.ts → type-plus/ts/mix_types/merge.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { describe, expect, it } from '@jest/globals'
import { testType } from './index.js'
import { merge, type Merge } from './merge.js'
import { merge, testType, type Merge } from '../index.js'

describe('Merge', () => {

it('merges with any -> any', () => {
testType.equal<Merge<any, any>, any>(true)
testType.equal<Merge<{ a: 1 }, any>, any>(true)
Expand All @@ -25,47 +23,67 @@ describe('Merge', () => {
testType.equal<Merge<unknown, { a: 1 }>, { a: 1 }>(true)
})

it('merges with undefined -> never', () => {
testType.equal<Merge<undefined, undefined>, never>(true)
it('drops undefined', () => {
testType.equal<Merge<undefined, undefined>, undefined>(true)

// merging X with `undefined` in JavaScript will drop `undefined` as it has no properties.
const x = { a: 1 }
const y: any = undefined
const z = { ...x, ...y }
expect(z).toEqual({ a: 1 })

testType.equal<Merge<{ a: 1 }, undefined>, { a: 1 }>(true)
testType.equal<Merge<undefined, { a: 1 }>, { a: 1 }>(true)
})

it('drops null', () => {
testType.equal<Merge<null, null>, null>(true)

// merging X with `null` in JavaScript will drop `null` as it has no properties.
const x = { a: 1 }
const y: any = null
const z = { ...x, ...y }
expect(z).toEqual({ a: 1 })

// intersection with `undefined` gets `never` so that it will be dropped
testType.equal<{ a: 1 } & undefined, never>(true)
testType.equal<Merge<{ a: 1 }, undefined>, never>(true)
testType.equal<Merge<undefined, { a: 1 }>, never>(true)
testType.equal<Merge<{ a: 1 }, null>, { a: 1 }>(true)
testType.equal<Merge<null, { a: 1 }>, { a: 1 }>(true)
})

it('merges with void -> never', () => {
testType.equal<Merge<void, void>, never>(true)
it('merges with void -> T & void', () => {
testType.equal<Merge<void, void>, void>(true)

// intersection with `void` SHOULD gets `never` so that it will be dropped.
// but it is returning `{ a: 1 } & void` instead.
// @ts-expect-error
testType.equal<{ a: 1 } & void, never>(true)
// https://github.com/microsoft/TypeScript/issues/55700
// intersection with `void` kept the intersection as `T & void`.
testType.equal<{ a: 1 } & void, { a: 1 } & void>(true)

// here we align the behavior with `undefined`
testType.equal<Merge<{ a: 1 }, void>, never>(true)
testType.equal<Merge<void, { a: 1 }>, never>(true)
testType.equal<Merge<{ a: 1 }, void>, { a: 1 } & void>(true)
testType.equal<Merge<void, { a: 1 }>, { a: 1 } & void>(true)
})

it('ignore NonComposableTypes', () => {
testType.equal<{ a: 1 } & string, { a: 1 } & string>(true)
testType.equal<{ a: 1 } & number, { a: 1 } & number>(true)
testType.equal<{ a: 1 } & bigint, { a: 1 } & bigint>(true)
testType.equal<{ a: 1 } & boolean, { a: 1 } & boolean>(true)
testType.equal<{ a: 1 } & symbol, { a: 1 } & symbol>(true)
testType.equal<{ a: 1 } & null, never>(true)
testType.equal<{ a: 1 } & undefined, never>(true)
it('merges primitive types for its properties', () => {
testType.equal<Merge<number, boolean>, Merge<Number, Boolean>, {
toFixed: (fractionDigits?: number | undefined) => string,
toExponential: (fractionDigits?: number | undefined) => string,
toPrecision: (precision?: number | undefined) => string,
valueOf: (() => Object) & (() => boolean)
}>(true)

testType.equal<Merge<string, symbol>, Merge<String, Symbol>>(true)
testType.equal<Merge<() => void, bigint>, Merge<Function, BigInt>>(true)
})

it('merges array', () => {
testType.equal<Merge<['a', 'b'], { a: number }>, ['a', 'b'] & { a: number }>(true)

testType.canAssign<Merge<['a', 'b'], { concat: boolean }>, { concat: boolean }>(true)
})
})

describe(`${merge.name}()`, () => {
it('joining primitive types to anything will get the primitive type', () => {
merge(1, true)
// expect(r).toEqual(true)
})
it('', () => {
const r = merge({ a: 1 } as const, 2)
testType.equal<typeof r, { readonly a: 1 }>(true)
testType.equal<typeof r, { readonly a: 1 } & Number>(true)
expect(r).toEqual({ a: 1 })
})

Expand Down
61 changes: 61 additions & 0 deletions type-plus/ts/mix_types/merge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Box } from './box.js'
import type { IsNever } from '../never/never_type.js'
import type { IsNull } from '../null/null_type.js'
import type { Merge as ObjectMerge } from '../object/merge.js'
import type { Or } from '../predicates/logical.js'
import type { IsUndefined } from '../undefined/undefined_type.js'
import type { IsUnknown } from '../unknown/unknown_type.js'
import type { IsVoid } from '../void/void_type.js'

/**
* ⚗️ *transform*
* 🔢 *customizable*
*
* Merges type `A` and type `B`.
*
* This type performs the same operations as `{ ...a, ...b }` but at the type level.
*
* This is a more general type then `ObjectPlus.Merge<A, B>`,
* which constraints `A` and `B` to be `Record`.
*
* This type does not have such restrictions, and tries to handle the other types accordingly.
*/
export type Merge<A, B> =
Or<
IsNever<A>,
IsNever<B>,
never,
Or<
IsVoid<A>,
IsVoid<B>,
A & B,
Or<
IsUnknown<A>,
Or<IsUndefined<A>, IsNull<A>>,
B,
Or<
IsUnknown<B>,
Or<IsUndefined<B>, IsNull<B>>,
A,
ObjectMerge<
Box<A, { $notBoxable: {} }>,
Box<B, { $notBoxable: {} }>
>
>
>
>
>

/**
* Left join `a` with `b`.
*
* This returns the proper type of `{ ...a, ...b }`
*
* @example
* ```ts
* merge({ a: 1 }, {} as { a?: string | undefined }) // { a: number | string }
* ```
*/
export function merge<A, B>(a: A, b: B): Merge<A, B> {
return { ...a, ...b } as any
}
14 changes: 14 additions & 0 deletions type-plus/ts/mix_types/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,17 @@ Box<'abc'> // String

Box<undefined> // never
```

## [Merge](./merge.ts)

⚗️ *transform*
🔢 *customizable*

Merges type `A` and type `B`.

This type performs the same operations as `{ ...a, ...b }` but at the type level.

This is a more general type then `ObjectPlus.Merge<A, B>`,
which constraints `A` and `B` to be `Record`.

This type does not have such restrictions, and tries to handle the other types accordingly.
3 changes: 2 additions & 1 deletion type-plus/ts/object/merge.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, it } from '@jest/globals'
import { ObjectPlus, testType } from '../index.js'
import { testType, type ObjectPlus } from '../index.js'


it('merges with any -> any', () => {
testType.equal<ObjectPlus.Merge<any, any>, any>(true)
Expand Down
16 changes: 11 additions & 5 deletions type-plus/ts/object/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ import type { IsDisjoint } from './IsDisjoint.js'
import type { KeyTypes } from './KeyTypes.js'
import type { OptionalKeys } from './OptionalKeys.js'

/**
* ⚗️ *transform*
* 🔢 *customizable*
*
* Merges type `A` and type `B`.
*
* This type performs the same operations as `{ ...a, ...b }` but at the type level.
*
* It handles cases like A or B are `Record`,
* joining between required and optional props, etc.
*/
export type Merge<A extends AnyRecord, B extends AnyRecord, Options = Merge.DefaultOptions> = Or<
IsAny<A>,
IsAny<B>,
Expand All @@ -30,11 +41,6 @@ export type Merge<A extends AnyRecord, B extends AnyRecord, Options = Merge.Defa
{ [k in PKA & PKB]?: A[k] | B[k] },
unknown
> &
// (NotNeverType<
// Exclude<KA & KB, PKA | PKB>,
// { [k in Exclude<KA & KB, PKA | PKB>]: Merge.JoinProps<A[k], B[k]> },
// unknown
// >) &
// properties only in A excluding partials is A[k]
NotNeverType<
Exclude<KA, PKA | KB>,
Expand Down
14 changes: 14 additions & 0 deletions type-plus/ts/object/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,20 @@ import type { OptionalProps } from 'type-plus'
type R = OptionalProps<{ a?: number; b: string }> // { a?: number }
```
## [ObjectPlus.Merge](./merge.ts)
`Merge<A, B, Options = { }>`
⚗️ *transform*
🔢 *customizable*
Merges type `A` and type `B`.
This type performs the same operations as `{ ...a, ...b }` but at the type level.
It handles cases like A or B are `Record`,
joining between required and optional props, etc.
## References
- [Handbook]
Expand Down

0 comments on commit 69c5409

Please sign in to comment.