Home > Enterprise >  Why do multiple inherited types in a union not work in typescript generics?
Why do multiple inherited types in a union not work in typescript generics?

Time:07-12

I have two type declarations where one accepts strings and the other one only numbers as keys.

The testFunction does not compile, stating that the properties do not exist for the type MyStringKeyObject | MyNumberKeyObject.

Can someone explain?

type MyStringKeyObject = {
  [key: string]: boolean;
};

type MyNumberKeyObject = {
  [key: number]: boolean;
};

const testFunction = <T extends MyStringKeyObject | MyNumberKeyObject>(
  generic: T
): void => {
  generic[1];
  generic.doesNotCompile;
};

Playground link

The tsconfig I am using:

{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": [
    "src"
  ]
}

CodePudding user response:

The situation is slightly unusual because you have a pair of indexed types, but then you have hardcoded values you're using to index into them. Normally, you'd derive the keys from the object, so you wouldn't run into this problem. It arises in your case because of the explicit 1 and doesNotExist in the code.

The problem is that, at compile-time, generic may be MyStringKeyObject or MyNumberKeyObject. So TypeScript doesn't know which set of rules to apply, the rules for string-keyed objects or the rules for number-keyed objects.

The error for generic.doesNotExist makes perfect sense:

Property 'doesNotCompile' does not exist on type 'MyStringKeyObject | MyNumberKeyObject'.    Property 'doesNotCompile' does not exist on type 'MyNumberKeyObject'. (2339)

That expression is valid for MyStringKeyObject, but not for MyNumberKeyObject, but generic might be either of them.

The error for generic[1] is slightly different:

Element implicitly has an 'any' type because expression of type '1' can't be used to index type 'MyStringKeyObject | MyNumberKeyObject'.    Property '1' does not exist on type 'MyStringKeyObject | MyNumberKeyObject'. (7053)

I'm a bit surprised by that the error on generic[1] (but then, I'm still only fairly good at TypeScript) because if you were just using MyStringKeyObject or MyNumberKeyObject (but not a union of both of them), that would be valid for either. (It's allowed for string-keyed objects because in JavaScript, you can use a number to "index" into any object, and the number gets converted to string.) Still, though, you basically have the same issue as with the other one: TypeScript needs to know what rules to apply.

The solution is to find a way to tell one member of the union from the other, and to branch accordingly. But you can't ask an object whether it's a string-indexed object or a number-indexed object at runtime. So a couple of options for you:

  1. If you're going to use explicit keys like that in the code, use separate functions for MyStringKeyObject and MyNumberKeyObject. Since you're going to have to handle them separately anyway (because of the hardcoded keys), this seems logical.

  2. Another approach is to add a compile-time key to the object you can use to tell them apart; a union of types where they have a common property that lets you tell them apart ("discriminate" them) is a called a discriminated union.

Here's an example of #2:

type MyStringKeyObject = {
    __type__: "string";
} & {
    [key: string]: boolean;
};

type MyNumberKeyObject = {
    __type__: "number";
    [key: number]: boolean;
};

const testFunction = <T extends MyStringKeyObject | MyNumberKeyObject>(generic: T): void => {
    if (generic.__type__ === "number") {
        generic[1];
    } else {
        generic.doesNotCompile; // But it does now. :-)
    }
};

Playground link

In each branch of the if/else, TypeScript knows what type it's dealing with (the if narrows the type of generic to be one or the other of the union), so you can use those hardcoded property names.

CodePudding user response:

Your rules won't allow you to assume that the parameter extends one type or the other, so you may only use properties that overlap, which is none in this case. (Correction: Technically number keys overlap since they are implicitly translated to strings).

It seems like you may want an intersection rather than a union.

type MyStringKeyObject = {
  [key: string]: boolean;
};

type MyNumberKeyObject = {
  [key: number]: boolean;
};

const testFunction = <T extends MyStringKeyObject & MyNumberKeyObject>(
  generic: T
): void => {
  generic[1];
  generic['doesNotCompile'];
};

This would work because it says generic contains both string keys and number keys. Whereas yours says it might contain number keys or string keys.

With a union, you need to typecheck the parameter before assuming a certain type of key.

You can use classes alongside instanceof to typecheck the parameter.

class MyStringKeyObject {
  [key: string]: boolean;
}

class MyNumberKeyObject {
  [key: number]: boolean;
}

const testFunction = <T extends MyStringKeyObject | MyNumberKeyObject>(
  generic: T
): void => {
  if (generic instanceof MyNumberKeyObject) {
    console.log('number key', generic[1]);
  } else if (generic instanceof MyStringKeyObject) {
    console.log('string key', generic['string']);
  } else {
    console.log('Invalid parameter', generic);
  }
};

class NumberKeyExt extends MyNumberKeyObject {}
class StringKeyExt extends MyStringKeyObject {}

const numberKey = new MyNumberKeyObject();
const numberKeyExt = new NumberKeyExt();
const stringKey = new StringKeyExt();
const stringKeyExt = new MyStringKeyObject();
numberKey[1] = true;
numberKeyExt[1] = true;
stringKey['string'] = true;
stringKeyExt['string'] = true;
testFunction(numberKey); // number key true
testFunction(numberKeyExt); // number key true
testFunction(stringKey); // string key true
testFunction(stringKeyExt); // string key true

instanceof relies on the fact that the object was created using a constructor.

  • Related