Reputation: 824
I'm trying to access the properties of an object via bracket notation. For this I created a const enum
which represents a subset of the keys of said object.
Now, depending on what key is used, I want to perform an operation or just return the correct value.
However, for every branch in the switch statement (even the default
branch!!), Typescript shows me this (of course for numbers it's number
and for undefined in the default case it's undefined
):
Type 'string' is not assignable to type 'UserInput[T]'.
Type 'string' is not assignable to type 'never'.(2322)
Here's the code:
const enum UserInputInfo {
Name = 'name',
StartTime = 'start',
ValidUntil = 'validUntil',
Target = 'target',
Url = 'url',
Title = 'title'
}
interface UserInput {
start: number;
target: string;
title: string;
validUntil: number;
url: string;
name: string;
getName: () => string;
some: boolean;
other: string;
props: number;
}
// errors happen in each return line
function getUserInputInfo<T extends UserInputInfo>(infoType: T): UserInput[T] {
const userInput: UserInput = null as any as UserInput; // this is just to simplify the code snippet
switch (infoType) {
case UserInputInfo.Name:
return (userInput ? userInput.getName() : '');
case UserInputInfo.ValidUntil:
return (userInput ? userInput.validUntil : 0);
case UserInputInfo.StartTime:
return (userInput ? userInput.start : 0);
case UserInputInfo.Target:
return (userInput && userInput.target);
case UserInputInfo.Url:
return (userInput ? userInput.url : '');
case UserInputInfo.Title:
return (userInput ? userInput.title : '');
default :
return void 0;
}
}
What is Typescript trying to tell me? How can I fix this issue? It seems the only workaround is to assert each return value to as UserInput[T]
.
Upvotes: 1
Views: 548
Reputation: 329543
The issue here is because of a change introduced in TypeScript 3.5 to improve the soundness of writing to an indexed access type. In TypeScript 3.4 and below, your code would not have produced an error (well, except for returning undefined
at the end, which the compiler doesn't consider unreachable). In no version of TypeScript (to date, up to TS3.8) is the compiler actually able to verify that your code is safe; it's just that in older versions the compiler would silently fail to catch actual errors (e.g., case UserInputInfo.Name: return 1
) whereas in newer versions the compiler loudly fails to validate safe code like you've got.
The underlying problem is: you've got a generic value T extends UserInputInfo
which is a union of (essentially) string literals, and you're using control flow (via switch
/case
) to try to figure out which T
you've got and act accordingly. But the compiler does not use control flow analysis to narrow a generic type parameter T
from UserInputInfo
to, say, UserInputInfo.Name
. There's a longstanding open GitHub issue about this, microsoft/TypeScript#24085. Not sure if there will ever be much traction there but you might want to go and give it a 👍 if you want to slightly increase the chance that it will be addressed.
Until then, we have workarounds. By far the easiest one is to use an overload so that the implementation of your function is intentionally more loosely typed. This is sort of like reverting to the pre-TS3.5 behavior for unions:
function getUserInputInfo<T extends UserInputInfo>(infoType: T): UserInput[T];
function getUserInputInfo(infoType: UserInputInfo): UserInput[UserInputInfo] {
const userInput: UserInput = null as any as UserInput;
switch (infoType) {
case UserInputInfo.Name:
return (userInput ? userInput.getName() : '');
case UserInputInfo.ValidUntil:
return (userInput ? userInput.validUntil : 0);
case UserInputInfo.StartTime:
return (userInput ? userInput.start : 0);
case UserInputInfo.Target:
return (userInput && userInput.target);
case UserInputInfo.Url:
return (userInput ? userInput.url : '');
case UserInputInfo.Title:
return (userInput ? userInput.title : '');
}
}
That suppresses compiler warnings, and isn't completely unsafe, since you can't return something nutty like {a: 1}
when the compiler expects string | number
. But if you return a number
where there should be a string
, or vice versa (e.g., case UserInputInfo.Name: return 1
), it will happily compile (as in TS3.4-) and you'll get runtime errors (e.g., getUserInputInfo(UserInputInfo.Name).toUpperCase()
).
Another workaround (mentioned in a comment by person who implemented the soundness change) is to take advantage of the fact that if you have a value t
of type T
, and a key k
of type K extends keyof T
, then the type of t[k]
will be seen by the compiler as T[K]
. So if we can make a valid UserInput
, we can just index into it with infoType
. Maybe like this:
function getUserInputInfo2<T extends UserInputInfo>(infoType: T): UserInput[T] {
const userInput = null as UserInput | null;
const validUserInput: UserInput = {
get [UserInputInfo.Name]() { return userInput ? userInput.getName() : '' },
get [UserInputInfo.ValidUntil]() { return userInput ? userInput.validUntil : 0 },
get [UserInputInfo.StartTime]() { return userInput ? userInput.start : 0 },
get [UserInputInfo.Target]() {
return (userInput && userInput.target) || '' }, // not sure
get [UserInputInfo.Url]() { return userInput ? userInput.url : '' },
get [UserInputInfo.Title]() { return userInput ? userInput.title : '' },
some: false,
other: "",
props: 1,
getName: () => ""
}
return validUserInput[infoType];
}
That's more or less a translation of your switch
/case
version into an object indexing operation (except that target
might be false
in your version, which is not a string
). In actual code I'd expect that there'd be less of a mismatch between UserInput
and UserInputInfo
, or you could use Pick
like this:
function getUserInputInfo3<T extends UserInputInfo>(
infoType: T
): Pick<UserInput, UserInputInfo>[T] {
const userInput = null as UserInput | null;
const validUserInput: Pick<UserInput, UserInputInfo> = {
get [UserInputInfo.Name]() { return userInput ? userInput.getName() : '' },
get [UserInputInfo.ValidUntil]() { return userInput ? userInput.validUntil : 0 },
get [UserInputInfo.StartTime]() { return userInput ? userInput.start : 0 },
get [UserInputInfo.Target]() {
return (userInput && userInput.target) || '' }, // not sure
get [UserInputInfo.Url]() { return userInput ? userInput.url : '' },
get [UserInputInfo.Title]() { return userInput ? userInput.title : '' },
}
return validUserInput[infoType];
}
Okay, hope one of those helps you; good luck!
Upvotes: 3
Reputation: 10157
Typescript is telling you that you're extending an enum, which can be any string.
So UserInput[anythingOtherThanDefinedEnumKeys]
is never
, because UserInput doesn't have all the keys.
You can just do:
function getUserInputInfo(infoType: UserInputInfo): UserInput[UserInputInfo] {}
Upvotes: 1