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;
};
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:
If you're going to use explicit keys like that in the code, use separate functions for
MyStringKeyObject
andMyNumberKeyObject
. Since you're going to have to handle them separately anyway (because of the hardcoded keys), this seems logical.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. :-)
}
};
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.