Search code examples
typescriptcompiler-errorspropertiesintersectiondiscriminated-union

Typescript intersection with a union leads to non-existent properties


In the example below I define Typescript types to request data from an index.

There are two performant ways to retrieve a chunk of data from the index server, either by startKey,endKey or by startKey,limit (a count of keys).

I'm doing something wrong when combining these alternate cases to define requests in Typescript and I can't see what, unless my approach to intersect a union doesn't make sense or I don't understand typescript errors.

interface StartKey {
  startKey: string;
}

interface EndKey {
  endKey: string;
}

interface Limit {
  limit: number;
}

type KeyRange = StartKey & EndKey;

type KeyLimit = StartKey & Limit;

type KeyBounds = KeyRange | KeyLimit;

export type Options = {
    someparam:string
} & KeyBounds;

function retrieve(options:Options){
    const {
        startKey,
        endKey, //this line causes a compiler error
        limit, //this line causes a compiler error
    } = options;
} 

First of all I create the two alternate interfaces KeyRange (which has endKey) and KeyLimit (which has limit). Then I union those interfaces into a KeyBounds type. That KeyBounds type is then combined by intersection with other index-request-specific parameters when composing a request. For example requesting items using Options should be able to use either one or the other strategy to limit the returned results.

This playground shows the approach I'm currently taking and the surprising (to me) errors that I get from the definition of Options...

  • Property 'endKey' does not exist on type 'Options'.
  • Property 'limit' does not exist on type 'Options'.

I would expect there to be some path to get endKey OR limit, since Options includes a union of types which have those properties. At most one of them will be present at any one time, but that's just like having an optional property, which doesn't throw a compiler error.

The destructuring which causes the error is exactly when I'm trying to explicitly verify which of the alternate key bounds signatures has been requested, (I'm expecting one or other property to be unset).

By contrast, this code where they are explicitly optional treats the destructuring NOT as an error case, even though the endKey and limit might both be undefined for any particular object. I'm expecting an intersection with a union to result in a similar data structure, except the compiler knows there might be an endKey XOR a limit.

interface KeyRange {
  startKey:string
  endKey?:string
  limit?:string
}

function retrieve(range:KeyRange){
  const {
    startKey,
    endKey,
    limit,
  } = range;
}

Getting an error that neither exists at all on the resulting type (not even optionally) is surprising to me and suggests I've missed something. Can anyone tell me what I need to do to make these alternates valid?


Solution

  • In general you cannot access a property on a union-typed value unless that property key is known to exist in every member of the union:

    interface Foo {
      foo: string;
    }
    interface Bar {
      bar: string;
    }
    function processFooOrBar(fooOrBar: Foo | Bar) {
      fooOrBar.foo; // error!
      // Property 'foo' does not exist on type 'Foo | Bar'.
      // Property 'foo' does not exist on type 'Bar'
    }
    

    The error message is a little misleading. When the compiler complains that "property foo does not exist on type Foo | Bar" it really means "the property foo is not known to exist in a value of type Foo | Bar". It is certainly possible for the property to exist, but because a value of type Bar does not necessarily have such a property, the compiler warns you.


    If you have a value of a union type and want to access properties that exist on only some members of the union, you need to do some sort of narrowing of the value via a type guard. For example, you can use the in operator as a type guard (as implemented by microsoft/TypeScript#15256):

      if ("foo" in fooOrBar) {
        fooOrBar.foo.toUpperCase(); // okay
      } else {
        fooOrBar.bar.toUpperCase(); // okay
      }
    

    In your case that would mean splitting your destructuring into two cases:

      let startKey: string;
      let endKey: string | undefined;
      let limit: number | undefined;
      if ("endKey" in options) {
        const { startKey, endKey } = options;
      } else {
        const { startKey, limit } = options;
      }
    

    (This in type guard is useful but technically unsafe because object types are open and extendible in TypeScript. It is possible to get a Bar object with a foo property like this:

    const hmm = { bar: "hello", foo: 123 };
    processFooOrBar(hmm); // no error at compiler time, but impl will error at runtime
    

    so be careful... but in practice this happens rarely)


    The other way you could deal with this is to widen to a type which has explicit optional properties before doing the destructuring. You are already doing this as a workaround, but you don't need to touch the Options type itself. Just widen the options value from Options to something like StartKey & Partial<EndKey & Limit>:

    const widerOptions: StartKey & Partial<EndKey & Limit> = options;    
    const {
      startKey,
      endKey,
      limit,
    } = widerOptions;
    

    Finally, you can rewrite Options to be explicitly an "XOR" version where the compiler knows that if you check the property on the "wrong" side of the union the value will be undefined:

    type XorOptions = {
      startKey: string,
      endKey?: never,
      limit: number,
      someParam: string
    } | {
      startKey: string,
      endKey: string,
      limit?: never,
      someParam: string
    }
    

    This differs from your Options in that every member of the XorOptions union has an explicit mention of every property. Then you can destructure without issue:

    function retrieve2(options: XorOptions) {
      const {
        startKey,
        endKey,
        limit,
      } = options;
    }
    

    Playground link to code