How to Implement TypeScript Dictionary Types
What is a Dictionary Type
Whether coming from Node.js, Python, C# or any other programming language you’ve probably heard of using one of the following:
- Associative Array
- Map
- Symbol Table
- Dictionary
These are actually all referring to the same thing – that is:
“an abstract data type composed of a collection of (key, value) pairs, such that each possible key appears at most once in the collection.“
https://en.wikipedia.org/wiki/Associative_array
Lets take a look at what a regular object looks like in JavaScript.
// An untyped list of users with unique names
const uniqueUsers = {
john: {name: 'John Smith'},
steve: {name: 'Steve Miller'}
};
// This would not throw an error - which is bad!
uniqueUsers['ellie'] = {name: 'Ellie Holmes'};
As you can see, all we’re doing is creating an object called unique users which contains two entries in the key pair format. Naturally, being JavaScript you’re able to add any properties to the object as you wish. You’re also allowed to change the key type to what ever you wish.
For reliable and readable code this is very bad! Read below to see how we can fix this in TypeScript
Record Type
In TypeScript version 2.1 there was a new type introduced – Record. The Record type quickly allows us to construct an object type with fixed keys.
const uniqueUsers: Record<string, { name: string }> = {
john: { name: 'John Smith' },
steve: { name: 'Steve Miller' }
};
// This would now throw the error below
uniqueUsers['ellie'] = {name: 'Ellie Holmes'};
// Property 'name' is missing in type '{}' but required in type '{ name: string; }'.ts(2741)
Dynamic Dictionary Types
What if you wanted to have a reusable interface as a dictionary type? Well, we can use generics here and pass the generic type into the interface like so:
interface IUser {
name: string
};
interface GenericDict<T> {
[key: string]: T
}
const uniqueUsers: GenericDict<IUser> = {
john: { name: 'John Smith' },
steve: { name: 'Steve Miller' }
};
That’s great, but not really any more powerful than a Record type. What we really want to do here, is create our GenericDict have an exhaustive list of keys.
My first thought was to pass the key type as a union like this:
...
type UserNames = 'john' | 'steve';
type GenericDict<T> = {
[key: UserNames]: T
}
...
This however throws the error:
“An index signature parameter type cannot be a union type. Consider using a mapped object type instead.ts(1337)”
As the error suggests, we should implement a mapped object.
This is easy enough to do and only requires us to slightly modify our code.
...
type UserNames = 'john' | 'steve';
type GenericDict<T> = {
[key in UserNames]: T;
};
...
Now we can put it all together to get a completely reusable dictionary type.
type UserNames = 'john' | 'steve';
type GenericDict<I, T> = {
[key in UserNames]: T;
};
interface IUser {
name: string
};
const uniqueUsers: GenericDict<UserNames, IUser> = {
john: { name: 'John Smith' },
steve: { name: 'Steve Miller' }
};
Pre TypeScript Version 2.1
If you’re using a legacy project and want to know how to do this without using the Record type, you can manually create a dictionary object.
interface IUser {
name: string
};
const uniqueUsers: { [index: string]: IUser } = {
john: { name: 'John Smith' },
steve: { name: 'Steve Miller' }
};
As you can see, we’re simply describing an object type where the index is of type string and the key is of type IUser (defined above). We can make it a little more reusable and verbose by defining the dictionary as a type like so.
interface IUser {
name: string
};
type UniqueUserDict = { [index: string]: IUser };
const uniqueUsers: UniqueUserDict = {
john: { name: 'John Smith' },
steve: { name: 'Steve Miller' }
};
Key Space Restrictions
Given the above examples, what if you wanted to restrict the keys to a certain set? We can do this by declaring a type.
// Specify a type containing the names of our users names
type UserNames = 'john' | 'steve';
interface IUser {
name: string
};
const uniqueUsers: Record<UserNames , IUser> = {
john: { name: 'John Smith' },
steve: { name: 'Steve Miller' }
};
As above, we can also make this a little more reusable and verbose by creating a dictionary type.
Though is does look like a lot more code, when creating interfaces, types and dictionaries you can make much cleaner code and it becomes far simpler to stick to a DRY principles.
// Specify a type containing the names of our users names
type UserNames = 'john' | 'steve';
interface IUser {
name: string
};
type UniqueUserDict = Record<UserNames, IUser>
const uniqueUsers: UniqueUserDict = {
john: { name: 'John Smith' },
steve: { name: 'Steve Miller' }
};