1
votes

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?

2
KeyRange<K> | KeyLimit<K> only overlaps with one property startKey and type: D["type"]; only gives you the extra property type - Silvermind
@Silvermind what I'm expecting is that limit and endKey only sometimes exist, not that they don't exist at all and hence throw a compiler error. I'm expecting the intersection of the union to behave like the following playground where both endKey and limit are optional, but the destructuring of them (even when undefined) isn't an error typescriptlang.org/play?#code/… - cefn
@Silvermind I added that contrasting example to clarify the question. - cefn
Then perhaps you need it to be optional by using a partial union: type KeyBounds<K extends Key> = Partial<KeyRange<K>> & Partial<KeyLimit<K>>; - Silvermind
@Silvermind that resolves the error but introduces the unfortunate situation that the API now suggests that both endKey and limit might make sense together, even though they don't. The previous strategy deliberately defined ...Range or ...Limit as alternates so that only one or the other was ever prompted. I may have to give up on this, but the situation remains surprising and unexplained to me given I don't see the fundamental difference between the union construct and the optional construct which justifies an actual compiler error in one case but not the other. - cefn

2 Answers

2
votes

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

0
votes

Building on @jcalz suggestion I defined KeyBounds which can alternately be fulfilled by either endKey OR limit but not both, by introducing an Xor<A,B> type. This forces the property names from the unused path (either A or B) as having type never rather than simply having no definition.

Once there is a definition, even when it sometimes resolves to never, then the destructure can happen without error...

type Xor<A, B> =
  | (A & { [k in keyof B]?: never })
  | (B & { [k in keyof A]?: never });

interface StartKey {
  startKey: string;
}

interface EndKey {
  endKey: string;
}

interface Limit {
  limit: number;
}

type KeyBounds = StartKey & Xor<EndKey, Limit>;

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

function retrieve(options:Options){
    const {
        startKey,
        endKey,
        limit,
    } = options;
}