type-fest icon indicating copy to clipboard operation
type-fest copied to clipboard

MergeUnion

Open ozum opened this issue 2 years ago • 1 comments

Hi,

I would like to have merge types of two objects but have a union of "each" attribute individually (not whole object).

Example

interface A {
	id: number;
	serial: number;
}

interface B {
	id: string;
	serial: string;
}

type C = Partial<A> | Partial<B>;

// I want to have combined type below:
const c: C = { id:3, serial: "j" }; // ERROR: Both must be number or both must be string

I couldn't find the necessary types in type-fest. Is it possible?

If not, there is a solution. The idea is not mine, I found it here, could you please consider adding this:

interface A {
	id: number;
	serial: number;

}

interface B {
	id: string;
	serial: string;
}

type Compute<T> = { [K in keyof T]: T[K] } | never;
type AllKeys<T> = T extends any ? keyof T : never;
type MergeUnion<T, Keys extends keyof T = keyof T> = Compute<
  { [K in Keys]: T[Keys] } & { [K in AllKeys<T>]?: T extends any ? (K extends keyof T ? T[K] : never) : never }
>;

type C = Partial<A> | Partial<B>;
type D = MergeUnion<A | B>;

const c: C = { id:3, serial: "j" }; // ERROR
const d: D = { id:3, serial: "j" }; // OK

Upvote & Fund

  • We're using Polar.sh so you can upvote and help fund this issue.
  • The funding will be given to active contributors.
  • Thank you in advance for helping prioritize & fund our backlog.
Fund with Polar

ozum avatar Apr 28 '23 09:04 ozum

The util above needs to be distributive over Keys or it breaks merging multiple keys:

type T = MergeUnion<{ a: string, b: number } | { a: string, b?: number }
// Actual: { a: string | undefined, b?: number | undefined }
// Expect: { a: string,             b?: number | undefined }

type T = MergeUnion<{ a: string, b: number } | { a: string, b: undefined }>
// Actual: { a: string | undefined, b: number | undefined }
// Expect: { a: string,             b: number | undefined }

Updated to make it distributive, to use the existing utilities in type-fest, and to make the public interface of MergeUnion not include keys:

import type { Simplify, KeysOfUnion } from 'type-fest';

type _MergeUnionKnownKeys<
  BaseType extends object,
  Keys extends keyof BaseType = keyof BaseType,
> = {
  [K in Keys]: Keys extends K ? BaseType[Keys] : never;
};

export type MergeUnion<BaseType extends object> = Simplify<
  _MergeUnionKnownKeys<BaseType> & {
    [K in KeysOfUnion<BaseType>]?: BaseType extends object
      ? K extends keyof BaseType
        ? BaseType[K]
        : never
      : never;
  }
>;
Test Cases
// for testing results
function assertType<Expected>(): <Actual extends Expected>(
	...args: IsEqual<Actual, Expected> extends true
		? [actual: Actual]
		: [actual: Actual] & [expected: Expected] & "Types did not match"
) => Actual {
	return (actual) => actual
}

declare var x: any;

// Case: Present in both, same type
assertType<{ prop: string }>()(x as MergeUnion<{ prop: string } | { prop: string }>)

// Case: Present in both, different types
assertType<{ prop: string | number }>()(x as MergeUnion<{ prop: string } | { prop: number }>)

// Case: Present in both, both optional
assertType<{ prop?: string }>()(x as MergeUnion<{ prop?: string } | { prop?: string }>)

// Case: Only present in one
assertType<{ prop?: string }>()(x as MergeUnion<{ prop: string } | {}>)

// Case: Present in both, one is never
assertType<{ prop: string }>()(x as MergeUnion<{ prop: string } | { prop: never }>)

// Case: Present in both, one is unknown
assertType<{ prop: unknown }>()(x as MergeUnion<{ prop: string } | { prop: unknown }>)
// Q: Should this be `{ prop?: string }`

// Case: Present in both, one is undefined
assertType<{ prop: string | undefined }>()(x as MergeUnion<{ prop: string } | { prop: undefined }>)

// Case: Present in both, one is record
assertType<Simplify<{ prop: string | number } & { [k: string]: number | undefined }>>()(x as MergeUnion<{ prop: string } | Record<string, number>>)

// Case: Multiple keys, present in both, same type
assertType<{ prop: string, other: number }>()(x as MergeUnion<{ prop: string, other: number } | { prop: string, other: number }>)

// Case: Multiple keys, present in both, different types
assertType<{ prop: string | symbol, other: number | boolean }>()(x as MergeUnion<{ prop: string, other: number } | { prop: symbol, other: boolean }>)

// Case: Multiple keys, both optional
assertType<{ prop?: string, other?: number }>()(x as MergeUnion<{ prop?: string, other?: number } | { prop?: string, other?: number }>)

// Case: Multiple keys, optional only in one key in one union member
assertType<{ prop: string, other?: number }>()(x as MergeUnion<{ prop: string, other: number } | { prop: string, other?: number }>)

// Case: Multiple keys, maybe undefined only in one key in one union member
assertType<{ prop: string, other: number | undefined }>()(x as MergeUnion<{ prop: string, other: number } | { prop: string, other: undefined }>)

// Case: Multiple keys, only present in one
assertType<{ prop?: string, other?: number }>()(x as MergeUnion<{ prop: string, other: number } | {}>)

jamiebuilds-signal avatar Oct 07 '24 23:10 jamiebuilds-signal