Narrowing a string to a key of an object
Asked Answered
G

3

11

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.

Gambia answered 15/5, 2021 at 23:19 Comment(0)
V
7

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:

if (input === "foo" || input === "bar" || input === "baz") {
    console.log(o[input]); // okay
}

but you'll find that trying to make that less redundant will only lead to more errors:

if ((["foo", "bar", "baz"] as const).includes(input)) { } // error!
// -----------------------------------------> ~~~~~
// Argument of type 'string | null' is not assignable to parameter
// of type '"foo" | "bar" | "baz"'.

(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 on k, in addition to the current support for it to act as a type guard on o (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 intend s in o to narrow s 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:

const p = {
    foo: 1,
    bar: 2,
    baz: 3,
    qux: "howdy"
};
const q: typeof o = p; // no error, object types are extendible

function isKeyOfQ(s: string): s is keyof typeof q {
    return s in q;
}

if (input && isKeyOfQ(input)) {
    input // "foo" | "bar" | "baz"
    console.log(q[input].toFixed(2)); // no compiler error
    // but what if input === "qux"?
}

Here, the compiler sees q as having the same type as o. Which is true, despite there being an extra property named qux. isKeyOfQ(input) will erroneously narrow input to "foo" | "bar" | "baz"... and therefore the compiler thinks q[input].toFixed(2) is safe. But since q.qux that property value is of type string while the others are of type number, 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 as input === "foo" || input === "bar" || input === "baz" or some other user-defined type guard dealing implemented via ["foo", "bar", "baz"].includes(input):

function isSafeKeyOfQ(s: string): s is keyof typeof q {
    return ["foo", "bar", "baz"].includes(s);
}

if (input && isSafeKeyOfQ(input)) {
    console.log(q[input].toFixed(2)); // safe
}

Playground link to code

Vinegarish answered 16/5, 2021 at 1:39 Comment(1)
Thank you for the rich context! The unsoundness issue is very interesting. My experience with static typing has never included constructs as dynamic as JavaScript objects, so it's not something that would have crossed my mind.Gambia
A
1

I believe this is the kind of approach you are after...

const o = {
  foo: 1,
  bar: 2,
  baz: 3,
} as const;

function printRequestedKey<Lookup extends Readonly<object>>(lookup: Lookup){
  const input = prompt("Enter the key you'd like to access");
  if (input !== null && input in lookup) {
    console.log(lookup[input as keyof typeof lookup]);
  }
}

printRequestedKey(o);

As per Aadmaa's answer it adds const to your definition of o. It also introduces a Generic binding so that if you're doing anything other than console logging, then the returned value can be some projection of the source object. The javascript guard input in lookup along with the use of Readonly object gives me confidence that the explicit cast input as keyof typeof lookup can't introduce runtime errors. I added a null check because prompt can return null (maybe via a key escape?).

Attaint answered 16/5, 2021 at 1:34 Comment(0)
D
-1

Your way is good. If you prefer you can do something like

const o = {
    foo: 1,
    bar: 2,
    baz: 3,
  } as const;

  type KeyO = keyof typeof o;

  const input = prompt("Enter the key you'd like to access in `o`");

  if (input in Object.keys(o)) {
      const typedInput = input as KeyO;
  }
Daph answered 15/5, 2021 at 23:36 Comment(1)
This doesn't seem safe because it will still compile even if you remove the input in Object.keys(o) conditional. (It's also not checking for the case where input is null, but I assume that was just an oversight in the example code.) The type assertion (input as Key0) seems to throw away type safety by forcing the compiler to treat the value as a type that it might not be.Gambia

© 2022 - 2024 — McMap. All rights reserved.