When using TypeScript, we usually strive to define the exact type of data we’re dealing with. Sometimes it is not possible, though. In this article, we look into various options on how to handle an object that has a structure we don’t know. A good example is implementing a JSON editor.
The object type
One of the first things that might come to mind when working with an object we know nothing about is the object type.
The data types in JavaScript fall into one of the two categories: primitive values and objects.
1. Primitive values
- Boolean
- Null
- Undefined
- Number
- BigInt
- String
- Symbol
2. Objects
Every value that is not primitive is considered an object, including arrays, and this is what the object type in TypeScript describes. Unfortunately, there is a big chance that it might not fit your use case. The above is an issue big enough that the @typescript-eslint/eslint-plugin package used to throw an error by default when using the object type. The plugin maintainers changed it just a few months ago.
Don’t use object as a type. The object type is currently hard to use (see this issue).
Consider using Record<string, unknown> instead, as it allows you to more easily inspect and use the keys.
The issue with the object type is that it is not straightforward to use.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
const user = { firstName: 'John', lastName: 'Smith' } const fullNameProperties = ['firstName', 'lastName']; const fullName = createStringFromProperties( user, fullNameProperties ); console.log(fullName); // John Smith function createStringFromProperties(dictionary: object, properties: string[]) { return properties.reduce((result, propertyName) => { if (propertyName in dictionary) { const value = dictionary[propertyName]; if (result) { return `${result} ${value}`; } return value; } return result; }, ''); } |
Unfortunately, the above code causes the following error:
Element implicitly has an ‘any’ type because expression of type ‘string’ can’t be used to index type ‘{}’.
No index signature with a parameter of type ‘string’ was found on type ‘{}’.
The above issue is caused by the in operator’s inability to widen the object type when used. People have discussed this issue for years, and a PR might fix it. Until then, we should look for other solutions.
The Object type with the uppercase “O”
The Object type looks very similar, and we need to watch out for it. It describes instances of the Object class, but its usage is discouraged in the context of dictionaries.
The @typescript-eslint/eslint-plugin package complains when we use the Object type:
Don’t use Object as a type. The Object type actually means “any non-nullish value”, so it is marginally better than unknown.
– If you want a type meaning “any object”, you probably want Record<string, unknown> instead.
– If you want a type meaning “any value”, you probably want unknown instead.
Unfortunately, the error message is correct, and the Object type accepts primitive values other than null. It might be the case because we can access the properties of Object.prototype via the primitive values.
1 2 3 |
console.log( (true).hasOwnProperty === Object.prototype.hasOwnProperty ) // true |
Because of the above, the following code does not result in an error:
1 2 |
const isValid: Object = true; console.log(isValid); // true |
The index signature
The index signature is a fitting way to handle objects with properties we know nothing about. Its syntax mimics a regular property, but instead of writing a standard property name, we define the type of keys and the properties.
1 2 3 |
type Dictionary = { [key: string]: unknown; } |
Above, we state that a Dictionary is an object that can have any number of properties of type unknown. Therefore, we can use it with the createStringFromProperties function we’ve defined at the beginning of this article.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
const user = { firstName: 'John', lastName: 'Smith' } const propertiesToTakeIntoAccount = ['firstName', 'lastName']; const fullName = createStringFromProperties( user, propertiesToTakeIntoAccount ); function createStringFromProperties(dictionary: Dictionary, properties: string[]) { return properties.reduce((result, propertyName) => { if (propertyName in dictionary) { const value = dictionary[propertyName]; if (typeof value !== 'string') { return result; } if (result) { return `${result} ${value}`; } return value; } return result; }, ''); } |
Since we’ve set every property of the Dictionary to be of type unknown, we’ve had to check if they are strings above before using it.
If we’re confident that all properties of an object are strings, for example, we can reflect that in our type.
1 2 3 |
type Dictionary = { [key: string]: string; } |
It is essential to notice that the Dictionary type contains all possible properties, which might cause some issues for us.
1 2 3 4 5 6 7 |
type Dictionary = { [key: string]: string; } const dictionary: Dictionary = {}; dictionary.firstName.toUpperCase(); // TypeScript does not prevent that |
To deal with the above issue, we can modify the Dictionary type slightly.
1 2 3 |
type Dictionary = { [key: string]: string | undefined; } |
The limitations of the index signature
The index signatures have a few limitations that we should know of. For example, we can only use strings, numbers, and symbols for keys.
1 2 3 4 5 6 7 8 9 10 11 |
type StringDictionary = { [key: string]: unknown; } type NumberDictionary = { [key: number]: unknown; } type SymbolDictionary = { [key: symbol]: unknown; } |
TypeScript allows symbols for keys in the index signatures since version 4.4
We can define multiple index signatures, but we need to make sure the types of our properties are compatible with each other.
1 2 3 4 |
type Dictionary = { [key: string]: unknown; [key: number]: unknown; } |
The above type works fine because we’ve used unknown in both index signatures.
Unfortunately, It is also not straightforward to use enums and string unions.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
enum PossibleProperty { FirstName = 'firstName', LastName = 'lastName' } // This type causes an error type EnumDictionary = { [key: PossibleProperty]: unknown; } // This type also causes an error type UnionDictionary = { [key: 'firstName' | 'lastName']: unknown; } |
An index signature parameter type cannot be a literal type or generic type. Consider using a mapped object type instead.
We can deal with the above issue by creating multiple index signatures or using the in keyword.
1 2 3 |
type EnumDictionary = { [key in PossibleProperty]: unknown; } |
The {} type
It is also worth mentioning the {} type. Unfortunately, it does not mean “any empty object” and accepts any non-null value as well. The @typescript-eslint/eslint-plugin package throws an error when we use it.
Don’t use
{}
as a type.{}
actually means “any non-nullish value”.
1 2 |
const isValid: {} = true; console.log(isValid); // true |
If we want to define the type for an empty object for some reason, we can use the never type and an index signature.
If you want to know more about the never type, check out Understanding any and unknown in TypeScript. Difference between never and void
1 2 3 4 5 |
type EmptyObject = { [key: string]: never; } const emptyObject: EmptyObject = {}; |
Empty interfaces behave in the same way as the {} type, and this is why it is worth using the no-empty-interface ESLint rule.
The Record type
Instead of the index signatures, we can use the Record utility type. It is generic and accepts two types: the type of the keys and the type of the values.
1 2 3 4 |
function createStringFromProperties( dictionary: Record<string, unknown>, properties: string[] ); |
Above, we define the dictionaries argument to be an object that contains any number of properties of type unknown.
Let’s look under the hood of the Record type:
1 2 3 |
type Record<Key extends keyof any, Type> = { [Property in Key]: Type; }; |
From the above definition, we can see that uses the index signatures under the hood. It might come in handy and make our code a bit more readable. It also makes it simpler to work with enums and unions.
1 2 3 4 5 6 7 8 |
enum PossibleProperty { FirstName = 'firstName', LastName = 'lastName' } type EnumDictionary = Record<PossibleProperty, unknown>; type UnionDictionary = Record<'firstName' | 'lastName', unknown>; |
Summary
In this article, we’ve gone through multiple types that we can use when working with objects. We’ve learned about various types such as object, Object, and {}, that we probably should not use. To deal with our use case, we’ve learned what the index signature is. We’ve also got to know the Record type and how it can simplify our code in some instances. All of the above can come in handy when dealing with situations that involve dictionaries we know little about.