0

I have a union type that looks like this:

type Option1 = {
    items: string[];
}
type Option2 = {
    delete: true;
}
type Combined = Option1 | Option2;

I would like to define a new variable with the type of the items field:

const items_variable:Combined["items"] = ["a", "b"];

but this gives me the error error TS2339: Property 'items' does not exist on type 'Combined'.

If I was working with values rather than types (which all the existing answers I've seen assume), I could use type narrowing here, however I do not understand how I can do that on an existing type.

It also seems that it would work as expected if Option2 defined items but that does not make sense for my use case.

What type should I use for items_variable?

jakeanq
  • 127
  • 6

4 Answers4

3

This is how TS unions work. It is by design.

type Option1 = {
    items: string[];
}
type Option2 = {
    delete: true;
}
type Combined = Option1 | Option2;

type Keys = keyof Combined; // never

As you see keyof Combined returns never - empty set of keys.

Because Option1 and Option2 does not have any props in common, TS is unsure what property is allowed.

Let's say you have a function which expects either Option1 or Option2. In order to use Combined in a safe way you should use custom typeguards:

type Option1 = {
    items: string[];
}
type Option2 = {
    delete: true;
}
type Combined = Option1 | Option2;

type Keys = keyof Combined; // never

const hasProperty = <Obj, Prop extends string>(obj: Obj, prop: Prop)
    : obj is Obj & Record<Prop, unknown> =>
    Object.prototype.hasOwnProperty.call(obj, prop);

const handle = (union: Combined) => {
    if (hasProperty(union, 'items')) {
        const option = union; // Option1
    } else {
        const option = union; // Option2
    }

}

Or you can add common property:

type Option1 = {
    tag: '1',
    items: string[];
}
type Option2 = {
    tag: '2',
    delete: true;
}
type Combined = Option1 | Option2;

type CommonProperty = Combined['tag'] // "1" | "2"

const handle = (union: Combined) => {
   if(union.tag==='1'){
       const option = union // Option1
   }
}

Another, alternative way is to use StrictUnion.

type Option1 = {
    items: string[];
}
type Option2 = {
    delete: true;
}
type Combined = Option1 | Option2;

// credits goes https://stackoverflow.com/questions/65805600/type-union-not-checking-for-excess-properties#answer-65805753
type UnionKeys<T> = T extends T ? keyof T : never;

type StrictUnionHelper<T, TAll> =
    T extends any
    ? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, never>> : never;

type StrictUnion<T> = StrictUnionHelper<T, T>

type Union = StrictUnion<Combined>

const items_variable: Union['items'] = ["a", "b"]; // string[] | undefined

As you might have noticed, there is a small drawback, items_variable might be also undefined. This is because of union nature. It can be either one value or another.

1

The problem is that items is only a valid property of one of your union members. But anything you do with Combined must be valid for all union members. So Combined['items'] is not valid.


It's hard to advise more from your contrived code, but the simple answer to just not use the union:

const items_variable: Option1["items"] = ["a", "b"];

Or you could define the items property on all members of the union. Note that they do not need to have the same type.

type Option1 = {
    items: string[];
}
type Option2 = {
    delete: true;
    items: never;
}
type Combined = Option1 | Option2;

const items_variable:Combined["items"] = ["a", "b"]; // works

Playground


Or you could use an intersection to filter the union to just the type that has the property you want.

const items_variable: (Combined & { items: unknown })["items"] = ["a", "b"];

Or

const items_variable: (Combined & Option1)["items"] = ["a", "b"];

Playground


In order to access ['items'] on a type, then all members of that type must have that as an accessible property. What all these approaches share is that they make sure that all union members have this property.

Alex Wayne
  • 178,991
  • 47
  • 309
  • 337
  • I think `items: never;` is a good solution for my case - it allows access to the type while also clearly stating the intent of the code. – jakeanq Aug 09 '21 at 08:25
1

Maybe what you're looking for is an Intersection field instead of an Union field:

    type Combined = Option1 & Option2;
Shalom Peles
  • 2,285
  • 8
  • 21
0

Playground

type Option1 = {
  items: string[];
}
type Option2 = {
  delete: true;
}
type Combined = Option1 | Option2

type UnionKeys<T> = T extends T ? keyof T : never;

type Prop<T, K extends UnionKeys<T>> = K extends UnionKeys<T> ? T[K] : never

const items_variable: Prop<Combined, 'items'> = ["a", "b"];
Dean Xu
  • 4,438
  • 1
  • 17
  • 44