Skip to content

Instantly share code, notes, and snippets.

@everdimension
Last active May 15, 2025 10:33
Show Gist options
  • Save everdimension/22c9fc9dbb3bff160f179703e6fa32ac to your computer and use it in GitHub Desktop.
Save everdimension/22c9fc9dbb3bff160f179703e6fa32ac to your computer and use it in GitHub Desktop.
Union of objects in typescript

Problem

When we make a union of objects in typescript that do not have a discriminator, it provides neither convenience nor type safety:

type X = { a: string, b: string } | { c: string; d: string }

const x = { c: 'qwe', d: 'asd', a: undefined } as X // notice a: undefined

if (x.a) {} // Error: "Property 'a' does not exist on type 'X'."

Instead, it's recommended to use the "in" operator:

if ('a' in x) {
  x.a // string, wrong
  x.b // string, wrong
}

The above is wrong and not safe. You should do:

if ('a' in x && x.a != null) {} // but this is NOT enforced by typescript

Solution

A combined set of fields is something we can work with:

type X1 = 
  | { a: string, b: string, c?: undefined, d?: undefined  }
  | { c: string; d: string, a?: undefined, b?: undefined }

  
const x1 = { c: 'qwe', d: 'asd', a: undefined } as X1

if ('a' in x1) {
  x1.a // string | undefined, correct
}
if (x1.a) {
  x1.a // string, correct
  x1.b // string, correct
}

Conclusion

A typescript helper to achieve this may be useful:

type Result = UnionHelper<{ a: string, b: string } | { c: string, d: string }>
/** 
 * Now Result is
 * | { a: string, b: string, c?: undefined, d?: undefined  }
 * | { c: string; d: string, a?: undefined, b?: undefined }
 */
import type { KeysOfTuple, UnionHelper } from './UnionHelper.ts';
type Assert<T extends true> = T;
type IsEqual<A, B> =
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
? true
: false;
type Keys1 = KeysOfTuple<[{ a: string; b: string }, { z: string }]>;
type KeysTest1 = Assert<IsEqual<Keys1, "a" | "b" | "z">>;
type Keys2 = KeysOfTuple<[]>;
type KeysTest2 = Assert<IsEqual<Keys2, never>>;
type ResultEmpty = UnionHelper<{}>;
type UnionTestEmpty = Assert<IsEqual<ResultEmpty, {}>>;
type Result1 = UnionHelper<{ a: string }>;
type UnionTest1 = Assert<IsEqual<Result1, { a: string }>>;
type Result2 = UnionHelper<{ a: string } | { b: string }>;
type UnionTest2 = Assert<
IsEqual<Result2, { a: string; b?: undefined } | { a?: undefined; b: string }>
>;
type Result3 = UnionHelper<
{ a: string; b: string } | { c: string; d: string } | { z: number }
>;
type UnionTest3 = Assert<
IsEqual<
Result3,
| {
a: string;
b: string;
z?: undefined;
c?: undefined;
d?: undefined;
}
| {
a?: undefined;
b?: undefined;
c: string;
d: string;
z?: undefined;
}
| {
a?: undefined;
b?: undefined;
c?: undefined;
d?: undefined;
z: number;
}
>
>;
import { UnionToTuple } from "type-fest";
type MergeTupleObjects<T extends readonly unknown[]> = T extends []
? object
: { [K in T[number] as keyof K]: unknown };
export type KeysOfTuple<T extends readonly unknown[]> = keyof MergeTupleObjects<T>;
export type Cleanup<T> = T extends object
? { [P in keyof T]: Cleanup<T[P]> }
: T;
type TupleToCombinedUnion<T> = T extends [infer Head, ...infer Tail]
?
| Cleanup<
{ [K in keyof Head]: Head[K] } & {
[K in KeysOfTuple<Tail>]?: undefined;
}
>
| Cleanup<{ [K in keyof Head]?: undefined } & TupleToCombinedUnion<Tail>>
: never;
/**
* Creates an easier to work with Union by filling in exclusive properties
*
* @example
* type Input = { a: string; } | { b: string; c: string; }
* type Result = UnionHelper<Input>;
*
* Result is:
* | { a: string; b?: undefined, c?: undefined }
* | { a?: undefined; b: string, c: string }
*/
export type UnionHelper<T> = TupleToCombinedUnion<UnionToTuple<T>>;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment