Type Checking Object Indexes in TypeScript

Quantum Mob
5 min readNov 24, 2022

How can we use an arbitrary string or number to index an object’s properties in a type-safe manner? Read on to find out. Or skip to the end and copy-paste. We won’t be offended.

Let’s say we have the following object:

type-checkings objects

const accountingCategory = {
4: 'Inventory',
8: 'Capital Asset',
15: 'Expense Item',
}

This object maps the numeric id of an “accounting category” onto its associated label. We want to write a function that will convert some arbitrary number into its associated label. Say we pass in the number 4 - then the function returns the string 'Inventory'. But say we pass in the number 16 - in this case we want our function to return the number 16. Seems simple enough - we can write it using just one line:

const getAccountingCategory = (categoryId: number) =>
accountingCategories[categoryId] ?? categoryId

However, TypeScript isn’t happy with this.

Element implicitly has an 'any' type because expression of type 'number' can't be used to index type '{ 4: string; 8: string; 15: string; }'.
No index signature with a parameter of type 'number' was found on type '{ 4: string; 8: string; 15: string; }'.ts(7053)

TypeScript is correct to warn us here. categoryId could be any number, but our accountingCategories object is only equipped to handle 4 , 8 or 15.

We should be able to narrow the type of categoryId with a simple condition:

const getAccountingCategory = (categoryId: number) => {
if (categoryId in accountingCategories) {
return accountingCategories[categoryId]
}
return categoryId
}

However, TypeScript still reports the same error. It is not able to recognize that we have already checked whether or not categoryId is a key of accountingCategories. We expect categoryId to have been narrowed to 4 | 8 | 15 , but it still thinks that categoryId can be any number.

No matter how we implement the condition…

if (accountingCategories.hasOwnProperty(categoryId)) {
// must cast categoryId to string because Object.keys returns string[]
if (Object.keys(accountingCategories).includes(categoryId.toString()) {

… we still get the same error.

Turns out there is a simple way to fix this, with something called Index Signatures. From the TypeScript documentation:

Sometimes you don’t know all the names of a type’s properties ahead of time, but you do know the shape of the values. In those cases, you can use an index signature to describe the types of possible values.

We can change the type of accountingCategories to allow it to be indexed by any number:

const accountingCategories: { [index: number]: string } = {
4: 'Inventory',
8: 'Capital Asset',
15: 'Expense Item',
}

Now our original function…

const getAccountingCategory = (categoryId: number) =>
accountingCategories[categoryId] ?? categoryId

…won’t cause any TypeScript errors.

This solution is sufficient for eliminating the error, but it is not 100% correct. It introduces some curious behaviour. Consider the following lines:

const x = accountingCategories[15]
const y = accountingCategories[23]

Since accountingCategories is constant and was defined with key 15 set to value 'Expense Item', it seems reasonable to expect the type of x to be "Expense Item". And since 23 was not defined, we would expect the type of y to be undefined. However, TypeScript is not able to figure it out, and they are both typed as string | undefined.

We can do better. We were on the right track when we tried to narrow the type of categoryId down. Let’s go back to that version of our function.

const getAccountingCategory = (categoryId: number) => {
if (categoryId in accountingCategories) {
return accountingCategories[categoryId]
}
return categoryId
}

We know that accountingCategories can only be indexed by 4, 8, or 15. We need to find a way to narrow the type of categoryId down to 4 | 8 | 15. Then TypeScript will allow us to use it to index accountingCategories.

We can implement the desired behaviour using a type predicate. Type predicates allow us to manually define the logic that TypeScript will use to narrow the type of a variable.

function isAccountingCategory(categoryId: number): categoryId is 4 | 8 | 15 {
return [4, 8, 15].includes(categoryId)
}const getAccountingCategory = (categoryId: number) => {
if (isAccountingCategory(categoryId)) {
return accountingCategories[categoryId]
}
return categoryId
}

This works exactly as we expect. Inside the if block, the type of categoryId is 4 | 8 | 15 and we can use it to index accountingCategory without issue. However, we have repeated 4, 8, 15 several times. We can DRY this code out a bit.

Let’s start by defining the type of the index of accountingCategories

type AccountingCategory = 4 | 8 | 15
const accountingCategories: { [index in AccountingCategory]: string } = {
4: 'Inventory',
8: 'Capital Asset',
15: 'Expense Item',
}

So, technically we have written 4, 8, 15 twice, but at least it is type-checked both ways.

If we try to add a key to accountingCategories that isn’t part of AccountingCategory we will get an error:

Object literal may only specify known properties, and '16' does not exist in type '{ 4: string; 8: string; 15: string; }'.ts(2322)

And if we try to add another value to AccountingCategory without adding a matching key to accountingCategories:

Property '16' is missing in type '{ 4: string; 8: string; 15: string; }' but required in type '{ 4: string; 8: string; 15: string; 16: string; }'.ts(2741)

Let’s incorporate the AccountingCategory type and accountingCategory into our type predicate

function isAccountingCategory(categoryId: number): categoryId is AccountingCategory {
return accountingCategory.hasOwnProperty(categoryId)
}

Side note: It is tempting to use the in keyword instead of hasOwnProperty:

function isAccountingCategory(categoryId: number): categoryId is AccountingCategory {
return categoryId in accountingCategories
}

Doesn’t that look clean! However, the in keyword returns true for properties in the prototype chain as well as in the specified object:

'constructor' in accountingCategories // true
'__proto__' in accountingCategories // true
'hasOwnProperty' in accountingCategories // true

This means that unfortunately there are very few cases where it would be correct to use in over hasOwnProperty.

Finally, we can use isAccountingCategory to narrow the type of categoryId:

const getAccountingCategory = (categoryId: number) => {
if (isAccountingCategory(categoryId)) {
return accountingCategories[categoryId]
}
return categoryId
}

Inside the if statement, the type of categoryId has been correctly narrowed to our AccountingCategory type. We can use it to index accountingCategories without any issues.

Type inference in TypeScript is extremely powerful and it is best practice to rely on it as much as possible. However, limitations exist and sometimes we as humans know better than the computer. This is why we have type predicates — as a sort of escape hatch. However, you must be careful when implementing them. TypeScript won’t be able to check that your predicate makes sense — if it were able to do that we wouldn’t need them in the first place! Just remember to be mindful of the typical Javascript gotchas — like the fact that 0 and '' are falsy.

Full code snippet:

type AccountingCategory = 4 | 8 | 15
const accountingCategories: { [index in AccountingCategory]: string } = {
4: 'Inventory',
8: 'Capital Asset',
15: 'Expense Item',
}
function isAccountingCategory(categoryId: number): categoryId is AccountingCategory {
return accountingCategory.hasOwnProperty(categoryId)
}
const getAccountingCategory = (categoryId: number) => {
if (isAccountingCategory(categoryId)) {
return accountingCategories[categoryId]
}
return categoryId
}

--

--

Quantum Mob

Toronto-based Digital Innovation partner. We create digital roadmaps for organizations and launch digital products following a consultative approach.