Skip to content

Commit

Permalink
chore: merge intrim
Browse files Browse the repository at this point in the history
  • Loading branch information
unional committed Sep 12, 2023
1 parent 2a0d791 commit 40ce283
Show file tree
Hide file tree
Showing 2 changed files with 57 additions and 88 deletions.
77 changes: 40 additions & 37 deletions type-plus/ts/merge.spec.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,60 @@
import { describe, expect, it, test } from '@jest/globals'
import { describe, expect, it } from '@jest/globals'
import { testType } from './index.js'
import { merge, type Merge } from './merge.js'

describe('Merge', () => {

test('same type returns A', () => {
testType.equal<Merge<{ a: 1 }, { a: 1 }>, { a: 1 }>(true)
it('merges with any -> any', () => {
testType.equal<Merge<any, any>, any>(true)
testType.equal<Merge<{ a: 1 }, any>, any>(true)
testType.equal<Merge<any, { a: 1 }>, any>(true)
})

test('disjoint returns A & B', () => {
testType.equal<Merge<{ a: 1 }, { b: 1 }>, { a: 1, b: 1 }>(true)
testType.equal<Merge<{ a: 1 }, { b?: 1 }>, { a: 1, b?: 1 }>(true)
testType.equal<Merge<{ a?: 1 }, { b: 1 }>, { a?: 1, b: 1 }>(true)
testType.equal<Merge<{ a?: 1 }, { b?: 1 }>, { a?: 1, b?: 1 }>(true)
it('merges with never -> never', () => {
testType.equal<Merge<never, never>, never>(true)
testType.equal<Merge<{ a: 1 }, never>, never>(true)
testType.equal<Merge<never, { a: 1 }>, never>(true)
})

test('replaces property in A with property in B', () => {
testType.equal<
Merge<{ type: 'a' | 'b', value: string }, { value: number }>,
{ type: 'a' | 'b', value: number }
>(true)
})
it('drops unknown', () => {
testType.equal<Merge<unknown, unknown>, unknown>(true)

it('removes extra empty {}', () => {
// testType.equal<
// Merge<{ leaf: { boo(): number } }, { leaf: { foo(): number } }>,
// { leaf: { boo(): number } | { foo(): number } }
// >(true)
testType.equal<Merge<{ leaf: { boo(): number } }, {}>, { leaf: { boo(): number } }>(true)
// intersection type drops `unknown`. `Merge<A, B>` follows the same pattern.
testType.equal<{ a: 1 } & unknown, { a: 1 }>(true)
testType.equal<Merge<{ a: 1 }, unknown>, { a: 1 }>(true)
testType.equal<Merge<unknown, { a: 1 }>, { a: 1 }>(true)
})

it('appends types of optional prop to required prop', () => {
testType.equal<Merge<{ a: number }, { a?: string | undefined }>, { a: number | string }>(true)
})
it('merges with undefined -> never', () => {
testType.equal<Merge<undefined, undefined>, never>(true)

it('appends types of required prop to optional prop', () => {
testType.equal<Merge<{ a?: string | undefined }, { a: number }>, { a: number }>(true)
// 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)
})

it('combines type with required and optional props', () => {
testType.equal<Merge<{ a: number }, { b?: string }>, { a: number, b?: string }>(true)
it('merges with void -> never', () => {
testType.equal<Merge<void, void>, never>(true)

type R = Merge<
{ a: { c: number } },
{
a?: { d: string }
}
>
// 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)

testType.inspect<R>(t => t)
// testType.equal<R['a'], { c: number } | { d?: string | undefined }>(true)
// here we align the behavior with `undefined`
testType.equal<Merge<{ a: 1 }, void>, never>(true)
testType.equal<Merge<void, { a: 1 }>, never>(true)
})

it('both optional', () => {
testType.equal<Merge<{ a?: number }, { a?: string }>, { a?: number | string | undefined }>(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)
})
})

Expand Down Expand Up @@ -149,3 +150,5 @@ describe(`${merge.name}()`, () => {
// })
})
})

// TODO: array merge check
68 changes: 17 additions & 51 deletions type-plus/ts/merge.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,36 @@
import type { IsAny } from './any/any_type.js'
import type { NonComposableTypes } from './composable_types.js'
import type { IsNever } from './never/never_type.js'
import type { AnyRecord } from './object/any_record.js'
import type { IsDisjoint } from './object/IsDisjoint.js'
import type { KeyTypes } from './object/KeyTypes.js'
import type { OptionalKeys } from './object/OptionalKeys.js'
import type { Properties } from './object/properties.js'
import type { IsLiteral } from './predicates/literal.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'

/**
* Left join type `A` with type `B`.
*
* It handles cases like A or B are `Record`,
* joining between required and optional props, etc.
*/
export type Merge<A, B> = [A, B] extends [NonComposableTypes, unknown] ? B
: [A, B] extends [unknown, NonComposableTypes] ? A
: A extends AnyRecord ? B extends AnyRecord
? IsDisjoint<A, B> extends true
? A & B
: ([keyof A, keyof B] extends [infer KA extends KeyTypes, infer KB extends KeyTypes]
? (IsLiteral<KA> extends true
? (IsLiteral<KB> extends true
? ([OptionalKeys<A>, OptionalKeys<B>] extends [infer PKA extends KeyTypes, infer PKB extends KeyTypes]
?
// property is optional when both A[k] and B[k] are optional
{
[k in PKA & PKB]?: A[k] | B[k] | undefined
} &
{
[k in Exclude<KA & KB, PKA | PKB>]: Merge.JoinProps<A[k], B[k]>
} &
// properties only in A excluding partials is A[k]
{ [k in Exclude<KA, PKA | KB>]: A[k] } &
// properties only in B excluding partials is B[k]
{ [k in Exclude<KB, PKB>]: B[k] } &
// properties is required in A but optional in B is unionized without undefined
{ [k in Exclude<KA & PKB, PKA>]: Exclude<A[k] | Partial<B[k]>, undefined> }
: never)
: Properties<
{ [k in Exclude<KA, KA & KB>]: A[k] } &
{ [k in Exclude<KB, KA & KB>]: B[k] } &
{ [k in KA & KB]: A[k] | B[k] }
>)
: (IsLiteral<KB> extends true
? { [k in Exclude<KA, KB>]: A[k] } & { [k in keyof B]: B[k] }
: { [k in Exclude<KA, KA & KB>]: A[k] } & { [k in Exclude<KB, KA & KB>]: B[k] } & {
[k in KA & KB]: A[k] | B[k]
}))
: never)
: never : never

export namespace Merge {
export type JoinProps<A, B> = A extends NonComposableTypes
? B
: (B extends NonComposableTypes
? A
: A & B)
}

export type Merge<A, B> = Or<IsAny<A>, IsAny<B>, any,
Or<Or<IsNever<A>, Or<IsUndefined<A>, IsVoid<A>>>, Or<IsNever<B>, Or<IsUndefined<B>, IsVoid<B>>>, never,
IsUnknown<A, B, IsUnknown<B, A,
[A, B] extends [NonComposableTypes, unknown] ? B
: [A, B] extends [unknown, NonComposableTypes] ? A
: (A extends AnyRecord
? (B extends AnyRecord ? ObjectMerge<A, B> : never)
: never)>>
>>
/**
* Left join `a` with `b`.
*
* This returns the proper type of `{ ...a, ...b }`
*
* @example
* ```ts
* leftJoin({ a: 1 }, {} as { a?: string | undefined }) // { a: number | string }
* merge({ a: 1 }, {} as { a?: string | undefined }) // { a: number | string }
* ```
*/
export function merge<A, B>(a: A, b: B): Merge<A, B> {
Expand Down

0 comments on commit 40ce283

Please sign in to comment.