Referencing Object Keys in Typescript
Javascript is very flexible about types. Imagine you have an object that represents a color, like this:
myColor = {
red: 1.0,
green: .5,
blue: 0
};
You can read and write properties on that object using both dot and bracket notation, try to read properties that don’t exist, and even add properties on at will.
myColor.red // read red (1.0)
myColor.red = 1.0 // write red
myColor["green"] // read green (.5)
myColor["green"] = .5 // write green
myColor.alpha // read an undefined property (undefined)
myColor.alpha = 1.0 // create and write a property
myColor["alpha"] = 1.0 // works too
Bracket notation will even let you access a property by a name stored in a variable.
targetChannel = "red";
myColor[targetChannel] // read red (1.0)
This can be pretty handy, Smudge uses this in a few places. It is also a little dangerous, its pretty easy to specify a key that doesn’t exist.
targetChannel = "silver";
myColor[targetChannel] // silver doesn't exist so (undefined)
Typescript
This is the sort of thing that Typescript helps with. Typescript provides type checking for Javascript, warning you when your code might have type errors at compile-time instead of run-time (or not at all).
Given the example above, Typescript can infer the structure of myColor
, and warn you when you try to access a key that doesn’t exist.
let myColor = {
red: 1.0,
green: 0.5,
blue: 0.0
}
myColor.red; // okay
myColor.silver; // warning: Property 'silver' does not exist on type '{ red: number; green: number; blue: number; }'
myColor["red"]; // okay
myColor["silver"]; // warning: Element implicitly has an 'any' type because type '{ red: number; green: number; blue: number; }' has no index signature.
Typescript gives us a warning when we try to access the nonexistent property named silver
.
The first error message is pretty clear, but the second one, about index signatures, may not be. Typescript understands that sometimes we may want to add and read keys dynamically. If we wanted to in this case, we could specify an index signature to tell typescript what types of data we’ll be working with. In this case though, we want typescript to warn us when we use a property name that doesn’t exist, so providing an index signature isn’t what we want.
When we use a string literal in brackets—myColor["red"]
—Typescript is able to verify that a matching key exists. A name stored in a const
would work also. But if we use a variable, Typescript infers the type as a string and can’t guarantee that all strings will properly name a key.
let targetChannel = "red";
myColor[targetChannel]; // warning
We need a way to tell Typescript that targetChannel
isn’t a general string
, but will always be “red”, “green”, or “blue”.
Helping Typescript Out
We need a way to define a new type, narrower than string. This type should
- only allow assignment of values in a user defined set of strings
- work as a
[]
index for objects that have keys for each string - work even if the object has additional keys than are not in our list
- support auto complete in the editor
There are a few approaches we might consider.
Approach 1, Enums
Specifying a type that consists of a specific set of values, an enumerated type or enum, is supported in a number of languages. Typescript does support enums, but they are numeric rather than string-based. Also, Typescript allows any number to be assigned to a enum-typed variable even if it’s not in the enumerated set.
Approach 2, keyof
Typescript has a keyof
operator for building types based on the keys of objects: type ChannelName = keyof typeof myColor;
. If we wanted every key in the object to be a valid value for ChannelName, this works great. This doesn’t work if we only want some of our object properties in our list of possible values.
Approach 3, String Literal Types
Typescript’s string literal specifies an exact value a string must have. Combining this with Typescript’s union type allow us to create a type that allows values from a list. Since variables of this new type will always be one of a set of values, Typescript can warn us when we try to use bad value and check that the variable names a key when we use it in a bracket index.
type ChannelName = "red" | "green" | "blue";
let targetChannel:ChannelName = "red";
let targetChannel2:ChannelName = "silver"; // warning: Type '"silver"' is not assignable to type 'ChannelName'.
myColor[targetChannel]; // okay!
This gives us the type safety we are looking for. We can go a step further and create a set of constants so that we don’t have the string literals in our code and will benefit from autocompletion in our editor. Putting it all together:
let myColor = {
red: 1.0,
green: 0.5,
blue: 0.0
}
type ChannelName = "red" | "green" | "blue";
const ChannelNames = {
red: <ChannelName>"red",
green: <ChannelName>"green",
blue: <ChannelName>"blue"
}
let targetChannel = ChannelNames.red;
// targetChannel's type will be infered as ChannelName
myColor[targetChannel]; // okay!
This is the approach I am using in Smudge, at least for now.
Approach 4, strEnum()
Basarat Ali Syed’s fantastic online book TypeScript Deep Dive offers a utility function for creating string based enums. This function lets us cut down the boiler plate for declaring the type and creating the constants to just this:
const ChannelNames = strEnum(['red','green','blue']);
type ChannelName = keyof typeof ChannelNames;
Unfortunately though, with this approach Typescript infers the type of targetChannel as a string unless we explicitly declare that targetChannel is a Channel Name.
let targetChannel = ChannelNames.red; // inferred type string
myColor[targetChannel]; // warning
let targetChannel: ChannelName = ChannelNames.red; // type ChannelName
myColor[targetChannel]; // okay!
Approach 5, Typescript 2.4
Typescript 2.4 which is in release candidate stage (as of June 12, 2017) is introducing string-based enums. I haven’t tried the RC, but there is a pretty good chance that these will be the best approach moving forward.