Note: TypeScript is invoked with tsc --strict
for all code shown below.
Given a singleton object o
:
const o = {
foo: 1,
bar: 2,
baz: 3,
};
If I have a string value (say from user input) that cannot be known at compile time, I want to safely use that string to index o
. I don't want to add an index signature to o
because it is not dynamic or extensible—it will always have exactly these three keys.
If I try simply using a string to index o
:
const input = prompt("Enter the key you'd like to access in `o`");
if (input) {
console.log(o[input]);
}
TypeScript reports this error, as expected:
error TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ foo: number; bar: number; baz: number; }'.
No index signature with a parameter of type 'string' was found on type '{ foo: number; bar: number; baz: number; }'.
console.log(o[input]);
~~~~~~~~
If I try changing the condition to verify at runtime that the value of input
is a key of o
:
if (input && input in o) {
console.log(o[input]);
}
This is not enough to convince TypeScript that the operation is safe, and the compiler reports the same error.
However, if I wrap the same logic for checking if input
is a key of o
in a custom type predicate, then the program compiles and works as expected:
function isKeyOfO(s: string): s is keyof typeof o {
return s in o;
}
if (input && isKeyOfO(input)) {
console.log(o[input]);
}
My question is: Are there any other ways to narrow a value of type string
into a value of type keyof typeof o
? I'm hoping there is another approach that is more concise. I'd also be interested in a generic solution to the use case of indexing an object with a dynamic string so that I don't need a type predicate that is specific to o
.
I don't think there's anything more concise that the compiler can verify as type safe. You can manually enumerate the possibilities like this:
but you'll find that trying to make that less redundant will only lead to more errors:
(See this question for more info, and also microsoft/TypeScript#36275 for why this wouldn't even act as a type guard).
So I won't suggest doing this in general (but see below).
There is an open suggestion at microsoft/TypeScript#43284 to allow
k in o
to act as a type guard onk
, in addition to the current support for it to act as a type guard ono
(see microsoft/TypeScript#10485). If that were implemented, your original check(input && input in o)
would just work with no errors.The issue in GitHub is currently open and marked as "Awaiting More Feedback"; so if you want to see this happen sometime, you might want to go there, give it a , and describe your use case if you think it's particularly compelling.
Personally I think the best solution here is probably your user-defined type guard function with the
s is keyof typeof o
type predicate, because it explicitly tells the compiler and any other developer that you intends in o
to narrows
in this way.Note that because object types are extendible, both your custom type guard and the proposed automatic type guarding from microsoft/TypeScript#43284 are technically unsound; a value of type
typeof o
may well have properties not known about, since object types in TypeScript are extendible or open:Here, the compiler sees
q
as having the same type aso
. Which is true, despite there being an extra property namedqux
.isKeyOfQ(input)
will erroneously narrowinput
to"foo" | "bar" | "baz"
... and therefore the compiler thinksq[input].toFixed(2)
is safe. But sinceq.qux
that property value is of typestring
while the others are of typenumber
, there is danger lurking.In practice this kind of unsoundness isn't a showstopper; there are some intentionally unsound behaviors in TypeScript where convenience and developer productivity is considered more important.
But you should be aware of what you're doing, so that you only use this kind of narrowing in situations where the provenance of your object is known; if you get
q
from some untrusted source, you might want something more provably sound... such asinput === "foo" || input === "bar" || input === "baz"
or some other user-defined type guard dealing implemented via["foo", "bar", "baz"].includes(input)
:Playground link to code