Raz Luvaton
Raz Luvaton

Reputation: 3790

How can I merge types back in TypeScript without touching the original types

Let's say I have the following types:

type A = {
    role: 'admin',
    force?: boolean
}

type B = {
    role: 'regular',
}

type Or = A | B;

I want to be able to do:

const or = {
    role: 'regular'
} as Or;

const {role, force} = or;

without getting the error:

Property 'force' does not exist on type 'Or'.(2339)

and without using Touching A and B

I tried to do:

type Merge<Obj> = {
    [k in keyof Obj]: Obj[k]
}

const {role, force} = or as Merge<Or>;

but same error

TypeScript Playground

Upvotes: 1

Views: 642

Answers (4)

Dmitri R117
Dmitri R117

Reputation: 2852

Redneck hack:

const {role, force} = or as any;

But seriously, you can't. Typescript is helping you out here. If you had done this, it would work:

const or: Or =
    role: 'admin'
};

const {role, force} = or;

This way the compiler matches it to the type A, which at least has a chance of having force, so force here is type boolean | undefined

Upvotes: 0

jcalz
jcalz

Reputation: 330456

TypeScript object types aren't sealed or exact (as requested in microsoft/TypeScript#12936). Just because a type doesn't mention a property, it doesn't prohibit the property. So technically speaking a value of type {role: "regular"} might have a property called force of some completely unknown type:

type A = { role: 'admin', force?: boolean };
type B = { role: 'regular' };
type Or = A | B;

const hmm = { role: 'regular', force: 9000 } as const;
const oops: Or = hmm; // <-- accepted

And by that logic is not safe for you to do what you're trying to do. The only safe destructuring of a value of type Or into its pieces would make the force property of type unknown.

But from here forward, let's assume that issue is not going to arise, and that we assert that the value only has properties known by the compiler, and any properties not mentioned in the type will be of type undefined.


How then can we "fix" a union type like Or to be a version that explicitly knows about all keys from the other members so that the compiler lets you index into it with any of these keys?

Here's one possible way:

type FixUnion<T, K extends PropertyKey = T extends unknown ? keyof T : never> =
    T extends unknown ? (
        T & { [P in Exclude<K, keyof T>]?: never } extends infer O ? {
            [P in keyof O]: O[P]
        } : never
    ) : never

First let's make sure it works on Or:

type FixedOr = FixUnion<Or>;
/* type FixedOr  = {
    role: 'admin';
    force?: boolean | undefined;
} | {
    role: 'regular';
    force?: undefined;
} */

That looks correct. Both union members have a role and a force property. The first member is the same as A, while the second member is B with an additional optional property of type undefined. So now you're allowed to destructure it:

const { role, force } = or as FixUnion<Or>;
// const role: "admin" | "regular"
// const force: boolean | undefined

So how does it work? First let's look at the K type parameter. I'm actually just using a default value to compute all the keys of every member of the T union. The type T extends unknown ? keyof T : never is a distributive conditional type which breaks T into its union members, grabs their individual keys, and unites them back together into a new union. I sometimes call this operation AllKeys<T> as shown in Is it possible to get the keys from a union of objects? . For Or, K will be "role" | "force".

So now K is all the keys. The body of FixUnion<T> is another distributive conditional type; for each member of the T union, we intersect it with { [P in Exclude<K, keyof T>]?: never }. That's a mapped type with all optional keys (hence the ?) which are present in K but not in the current union member (using the Exclude<T, U> utility type). And the property values are never, the impossible type. For Or, the first member will look like A & {} and the second member will look like B & { force?: never }. Note that optional properties automatically get undefined in their domain, so it's the same as B & { force?: undefined }. The point is that the force property in the second union member is known to be either missing or undefined.

Finally, in order to make things prettier, I use a technique to turn intersections like A & {} into a plain object like {role: "admin", force?: boolean}, as described in How can I see the full expanded contract of a Typescript type? ; the ... extends infer O ? {[P in keyof O]: O[P]} : never} does that. So then the first member becomes {role: "admin", force?: boolean}, and the second becomes {role: "regular", force?: undefined}.


Let's just test it out on a different union, just to see how it works:

type Foo = FixUnion<{ a: 0 } | { b: 1 } | { a: 2 } | { c: 3 }>;
/* type Foo = {
    a: 0;
    b?: undefined;
    c?: undefined;
} | {
    b: 1;
    a?: undefined;
    c?: undefined;
} | {
    a: 2;
    b?: undefined;
    c?: undefined;
} | {
    c: 3;
    a?: undefined;
    b?: undefined;
} */

All four union members have all three keys, but at least two keys are optional keys of type undefined.

Playground link to code

Upvotes: 3

Abdelmonaem Shahat
Abdelmonaem Shahat

Reputation: 544

use the Pick utility to achieve that and here is my solution you might consider helpful:

type A = {
    role: 'admin',
    force?: boolean
}

type B = {
    role: 'regular',
}
type Or = Pick<A & B, 'force' | 'role'>;
const or = {
    role: 'regular'
} as Or;

const {role, force} = or;

Upvotes: 0

Alex Wayne
Alex Wayne

Reputation: 187272

You cannot access properties of a union type that only some members of that union type have.

You're getting the same error, and for the same reason is this simplified code:

type A = {
    role: 'admin',
    force?: boolean
}

type B = {
    role: 'regular',
}

const obj: A | B =
    Math.random() > 0.5 ?
        { role: 'admin', force: true } :
        { role: 'regular' }

obj.force // type error

If you want that property, then you have to prove that it exists first which narrows your union to just the values that that property.

if ('force' in obj) obj.force // fine

See Playground


You can add the same thing to your playground in order to make typechecking pass.

const mergedOr = or as Merge<Or>;
const force = 'force' in or && or.force; // fine

See Playground


It's also worth mentioning that this type:

type Merge<Obj> = {
    [k in keyof Obj]: Obj[k]
}

Does nothing useful. It maps an object types keys to the value types for those keys, which will exactly the same as the input type. So whatever you think this is supposed to do, it's not doing that.

Upvotes: 2

Related Questions