Everyday TypeScript: Index Signatures
Welcome to the Index Signatures lesson!
This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!
We've seen object types that include specific keys:
>
type User = {name: string};const amir: User = {name: 'Amir'};amir.name;Result:
'Amir'
What about objects where we don't know the keys in advance? For example, we might want a
LoginCountsobject type that maps users' names to how many times they've logged in. We can't explicitly write out the keys for that object type because we don't know all of the users' names at compile time.Fortunately, TypeScript has a way to express this type. The syntax is based on ES2015's "computed keys" feature, so we'll review that first.
When creating an object, we can write the keys as unquoted words, like
Amirin this example:>
const loginCounts = {Amir: 5};loginCounts.Amir;Result:
5
We can also create objects with computed keys. To do that, we wrap the key in square brackets, like
{[someVariable]: 1}:>
const name = 'Amir';const loginCounts = {[name]: 5};loginCounts.Amir;Result:
The [square brackets] can contain any expression, not just simple variables. For example, we can use string concatenation to build the key dynamically:
>
const loginCounts = {['Be' + 'tty']: 7};loginCounts.Betty;Result:
7
TypeScript has a similar syntax for defining object types with arbitrary keys. Here's a
LoginCountstype for objects that map strings to numbers:>
type LoginCounts = {[userName: string]: number};const loginCounts: LoginCounts = {Amir: 5,Betty: 7,};loginCounts.Betty;Result:
7
Any object that violates the type will cause a type error. (You can indicate type errors with
type error.)>
type LoginCounts = {[userName: string]: number};const loginCounts: LoginCounts = {Amir: 5,Betty: 'seven',};Result:
type error: Type 'string' is not assignable to type 'number'.
Objects with these types are still regular objects. The type just describes the type of the keys and the values.
>
type LoginCounts = {[userName: string]: number};const loginCounts: LoginCounts = {Amir: 5,Betty: 7,};loginCounts;Result:
In our type above, we named the key
userName. TypeScript doesn't care whether we name ituserNameorkeyorsor anything else. But it's a good idea to use a name that indicates intent. We intend for this object's keys to be users' names, anduserNamecommunicates that to the next person who reads our type.This kind of type is called an "index signature". "Index" because it's defining the indexes (that is, the keys) of an object. "Signature" because it defines the object's overall signature, in the same way that the type
(n: number) => stringdefines a function's signature.Here's a code problem:
Write the index signature for an object that maps a person's name (a string) to a city (a string or
undefined).type Cities = {[userName: string]: string | undefined};const cities: Cities = {Amir: 'Paris',Betty: 'Nassau',Cindy: undefined,};cities;- Goal:
{Amir: 'Paris', Betty: 'Nassau', Cindy: undefined}- Yours:
{Amir: 'Paris', Betty: 'Nassau', Cindy: undefined}
Index signatures' key types must be strings or numbers. When they're numbers, they define an array type. Here's an array type written normally:
>
const strings: string[] = ['a', 'b', 'c'];strings[0];Result:
'a'
Here's the same array type written using an index signature. We wouldn't normally do this, but it nicely demonstrates that JavaScript arrays are just objects with number keys.
>
const strings: {[index: number]: string} = ['a', 'b', 'c'];strings[0];Result:
'a'
It's possible to mix an index signature with explicit definitions of certain fields. When we do that, the other fields must match the index signature. You can think of the index signature as being the overall type for the object. Any individual fields must conform to that overall type; otherwise it's a type error.
>
type LoginCounts = {[userName: string]: numberAmir: number};const loginCounts: LoginCounts = {Amir: 5,Betty: 7,};loginCounts;Result:
{Amir: 5, Betty: 7}>
type LoginCounts = {[userName: string]: numberAmir: string};const loginCounts: LoginCounts = {Amir: 'five',Betty: 7,};loginCounts;Result:
type error: Property 'Amir' of type 'string' is not assignable to 'string' index type 'number'.
>
type LoginCounts = {[userName: string]: numberAmir: number};const loginCounts: LoginCounts = {Betty: 7,};loginCounts;Result:
type error: Property 'Amir' is missing in type '{ Betty: number; }' but required in type 'LoginCounts'.Finally, a warning about index signatures. Object access can always return an
undefined, even if it's not in the index signature type.Consider the index signature
{[key: string]: string}. It says "every key is a string that maps to a string value". But that's impossible because there's an infinite number of possible strings that could be keys!If we use that index signature, then access a key that doesn't exist, we'll get
undefined, even though there's noundefinedin the type! The type system will think that we got astringeven though it's actually anundefined.>
type UserEmails = {[name: string]: string};const userEmails: UserEmails = {Amir: 'amir@example.com'};userEmails.Amir;Result:
'amir@example.com'
>
type UserEmails = {[name: string]: string};const userEmails: UserEmails = {Amir: 'amir@example.com'};userEmails.Betty;Result:
undefined
This code is wrong, but unfortunately it's allowed by the compiler.
>
type UserEmails = {[name: string]: string};const userEmails: UserEmails = {Amir: 'amir@example.com'};const email: string = userEmails.Betty;email;Result:
undefined
This is one of the places where TypeScript's type system lets us do things that are wrong and dangerous. Fortunately, we can change this behavior with the
--noUncheckedIndexedAccesscompiler option. With that option enabled,userEmails.Bettyhas the typestring | undefined, accurately reflecting the fact that Betty may not be present in the object. If you combine the--noUncheckedIndexedAccesscompiler option with the--strictNullChecksoption, you'll get increased type safety at the cost of writing a little more code.