27

I have an object which contains some predefined data for my application, which is stored in a const variable like this:

const data:{[key:string]:any} =Object.freeze({
    some: 123,
    long: {"a":"b"},
    list: ["c"],
    of: "",
    arbitrary: null,
    things: 1.2,
});

The keys of this object are known to the rest of the application. Consider this function which accesses the data object:

function doWork(k) {
    if(!data.hasOwnProperty(k)) throw Error();
    let value = data[k];
    //...
}

This is called with strings like

doWork("things");

I would like to replace that runtime error for invalid keys with a Typescript compile-time check. I would like to be able to write

function doWork(k: keyof data) {
    let value = data[k];
    //...
}

But apparently the keyof operator does not work that way. I get an error TS2304: Cannot find name 'data'.


My workaround: I can extract the keys of the object with something like this:

console.log("\""+Object.keys(data).join("\"|\"")+"\"");

Which I can then copy/paste and define as a type.

type data_key = "some"|"long"|"list"|"of"|"arbitrary"|"things"
export function doWork(k:data_key) {
    let value = data[k];
    //...
}

This feels like a silly hack and is quite inconvenient whenever I have to make a change, because I have to remember to put in this statement at the right place, run the program, and manually copy in the values back into the source code (or realistically, just type in the changes myself).


I am open to a better solution. Is there a language feature that provides the functionality I am looking for?

Minette Napkatti
  • 271
  • 1
  • 3
  • 5

1 Answers1

45

Let TypeScript infer the type of data, then extract the keys from the type it infers by using type data_key = keyof typeof data;:

const data = Object.freeze({
    some: 123,
    long: {"a":"b"},
    list: ["c"],
    of: "",
    arbitrary: null,
    things: 1.2,
});

type data_key = keyof typeof data;

function doWork(k: data_key) {
    let value = data[k];
    //...
}

On the playground.

How that works:

  1. TypeScript has advanced type inference and so is able to infer the type of the object initializer passed into Object.freeze with the keys some, long, list, etc. Object.freeze is defined as freeze<T>(o: T): Readonly<T>¹ so it returns a Readonly version of that same inferred type.
  2. keyof gets the keys of a type.
  3. typeof in this context is TypeScript's typeof, not JavaScript's. In TypeScript, there are places it expects runtime value, and other places it expects compile-time types. keyof's operand is a place where a compile-time type is expected, so typeof data returns the compile-time type of data, which is the Readonly version of the inferred type from freeze (with the keys some, long, list, etc.). (If you had x = typeof data, that would be typeof in a context where a runtime value is expected, so it would be JavaScript's typeof, and x would get the runtime value "object".)

¹ There are actually three definitions of freeze in lib.es5.d.ts (one for arrays, one for functions, and one for all other kinds of objects; this is that last one).

George
  • 36,413
  • 9
  • 66
  • 103
T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
  • 1
    @Shinigami - I've only been very casually learning TypeScript the past couple of months and am routinely blown away by how advanced its handling of things is. :-) – T.J. Crowder May 26 '19 at 09:20
  • It looks like this solution is incompatible with a type declaration on the object itself. Say if I declared an object as `const numbers:{[key:string]:number} ={"one":1,"two":2};`. Then `keyof typeof numbers` just gives `string|number`. It looks like the [key:string] is causing the problem. Is there any way around this? – Minette Napkatti May 26 '19 at 10:07
  • @MinetteNapkatti - Right, if you provide an explicit type declaration, that's going to win, TypeScript isn't going to infer (except to ensure that what you're assigning is compatible with the type). I don't understand the question in that comment. The "way around" this is not to do that. If you want `numbers` to only have the keys `one` and `two`, use the above (let TypeScript infer the type). If you want numbers to allow **any** string key, use your `{[key:string]:number}` type. – T.J. Crowder May 26 '19 at 10:17
  • 1
    @T.J.Crowder My bad, looks like I made the original example too generic and misrepresented my actual use case. Luckily I found a question about that exact problem: https://stackoverflow.com/questions/54598322/how-to-make-typescript-infer-the-keys-of-an-object-but-define-type-of-its-value – Minette Napkatti May 27 '19 at 03:08
  • @MinetteNapkatti - Glad you found that. Since this is a correct answer to the question actually posted, I think it's okay to accept the answer. Mind you, it's what *you* think that matters. :-) – T.J. Crowder May 27 '19 at 08:33