DEV Community

Cover image for How To Handle This Type Error
Dan Fletcher
Dan Fletcher

Posted on

How To Handle This Type Error

Can you spot the Type Error?

function getProperty<T>(obj: T, propertyName: string) {
  return obj[propertyName];
} 
Enter fullscreen mode Exit fullscreen mode

I posed this question on Twitter recently and it received some interesting responses:

The solution was intended to be rather simple, and in a way it is.

However there is some interesting nuance to this problem that I hadn't considered until people started commenting. Which then took me on a journey for a much more complicated solution.

For example, this comment, pointed out a flaw in my simplified version:

I thought it would be easier to unpack all of this in longform rather than a series of tweets.

What is the Type Error?

I hope you tried to figure it out for yourself first, but here's the answer:

Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'unknown'.
No index signature with a parameter of type 'string' was found on type 'unknown'.

What this error is saying, is that you can't index the obj parameter using propertyName. Essentially TypeScript is helping you avoid a few mistakes.

Mistake 1

obj might not be an object (remember arrays are objects in JavaScript too) which would cause a runtime error when we try to do obj[propertyName].

For example, what would prevent getProperty(123, 'name')? Nothing. But TS prevents it with a Type Error, thankfully 🙏

Mistake 2

Even if obj is an object, it might not have the key that we pass as the propertyName argument.

For example:

const prop = getProperty({name: 'Dan'}, 'age')
Enter fullscreen mode Exit fullscreen mode

In this case, prop would end up undefined, since 'age' isn't a property of {name: 'Dan'} (and TS would infer any as the return type from the function by the way -- also not good!)

The Fix

Now that we can spot the error, and why it's an issue, how can we fix this?

One approach might be to:

Narrow the type of T

function getProperty<T extends {}>(obj: T, propertyName: string) {
  return obj[propertyName];
} 
Enter fullscreen mode Exit fullscreen mode

What we've done is narrowed the type of T so that it's more specific. Now it has to be of type object.

This solves the first issue mentioned above, however it only changes the message of the Type Error to be:

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 '{}'.

So instead what you could do is:

function getProperty<T extends Record<string, any>(obj: T, propertyName: string) {
  return obj[propertyName];
} 
Enter fullscreen mode Exit fullscreen mode

Record<string, any> is basically saying that T is an object, where the keys are strings, but the values can be anything.

This will eliminate the Type Error and now the code will compile!

But this has a major problem...

function getProperty<T extends Record<string, any>>(obj: T, propertyName: string) {
  return obj[propertyName];
} 

// prop is now `any`
const prop = getProperty({name: 'Dan'}, 'age')

// Oops! prop is undefined, but no Type Error 😬
const shouldBeString: string = prop
Enter fullscreen mode Exit fullscreen mode

As you can see we still have the second issue mentioned above, which is that the getProperty method doesn't guarantee that the property we pass exists on the object.

A much better approach is to use unknown instead of any:

function getProperty<T extends Record<string, unknown>>(obj: T, propertyName: string) {
  return obj[propertyName];
} 

// prop is now `unknown`
const prop = getProperty({name: 'Dan'}, 'age')

// Prop is still undefined
// But now, TypeScript will force us
// to narrow it's type before we use it.
// So this is a Type Error!
const shouldBeString: string = prop
Enter fullscreen mode Exit fullscreen mode

By using unknown instead of any TS will force us to narrow the type before we use it.

The snippet above will be a Type Error since unknown can't be assigned to type string, so now you have to write a predicate or assertion to check that thing actually is of type string.

💡Tip: In general if you feel the need to use any in TypeScript, you should almost always use unknown instead.

This is useful to have, and I want to come back to this approach but there's another, simpler way to fix the issue.

Use keyof

The solution that I was trying to tease out of people on Twitter was to use keyof T as the type constraint for propertyName instead of using string.

Here is what that looks like:

function getProperty<T>(obj: T, propertyName: keyof T) {
  return obj[propertyName];
} 
Enter fullscreen mode Exit fullscreen mode

Quick refresher on the keyof operator:

In layman's terms, keyof is simply saying that a type must have a property of the type on the right hand side of the operator.

So for example if T is {name: 'Dan', age: 32} then keyof T is really just a union of string literals like this:

'name' | 'age'
Enter fullscreen mode Exit fullscreen mode

Remember that a string literal such as 'name' can be a type in TypeScript.

So by constraining propertyName to be keyof T we're saying that propertyName must match one of the keys on the given obj.

This solves both of the issues mentioned above!

function getProperty<T>(obj: T, propertyName: keyof T) {
  return obj[propertyName];
} 

// Now the Type Error is here, where we want it
// 'age' does not exist on type `{name: 'Dan'}`
const prop = getProperty({name: 'Dan'}, 'age')
Enter fullscreen mode Exit fullscreen mode

The nice thing about the solution above, is that it also allows the return type to be inferred properly.

Here's a couple examples:

// type will be 'string' | 'number'
getProperty({name: 'Dan', age: 32}, 'name') 

// type will be 'string' | 'number'
getProperty({name: 'Dan', age: 32}, 'age') 
Enter fullscreen mode Exit fullscreen mode

That's pretty neat right!?

This has problems too!

First of all, it would be nice if we received a more specific type back. Rather than 'string' | 'number' it would be nice if we got the actual type of the property we're pulling out of the object, right?

But that's not the only problem. There's a another issue here too.

This response from Twitter explains it well:

So with keyof T alone, this function doesn't always cause Type Errors when we might expect it too.

Here are a few examples where it works as expected, and others where it doesn't:

A screenshot of TypeScript code detailing the examples discussed above

Why? Because JS is weird and everything that's not an object still kind of acts like one.

For example, try this stuff in your JS console:

'123'['charAt'] // f charAt() { [native code] }
123['toExponential'] // f toExponential() { [native code] }
true['valueOf'] // f toValueOf() { [native code] }
Enter fullscreen mode Exit fullscreen mode

So this implementation still has some weird behaviour.

But what if we combined the two approaches?

Use both extends and keyof

We're almost at the final solution!

Let's try combining both approaches from above and see what happens. But first lets clean this function up a bit. The types are becoming a bit unruly:

// Start by extracting the type definition to a new type alias
type GetProperty = <T>(obj: T, propertyName: keyof T) 
  => typeof obj[keyof T]

// Then update the function declaration to use the type
const getProperty: GetProperty = (obj, propertyName) => {
  return obj[propertyName];
}
Enter fullscreen mode Exit fullscreen mode

Ok great, now we can focus on just the types without the noise of the function itself getting in the way.

So lets add that <T extends Record<string, unknown>> bit to this implementation and see what we get:

type GetProperty = <T extends Record<string, unknown>>
  (obj: T, propertyName: keyof T) 
    => typeof obj[keyof T]

const getProperty: GetProperty = (obj, propertyName) => {
  return obj[propertyName];
}

// These now cause type errors!
getProperty('123', 'charAt')
getProperty(123, 'toExponential')
getProperty(true, 'valueOf')

// works as it did above but type is still 'string' | 'number'
const age = getProperty({name: 'Dan', age: 32}, 'age')
Enter fullscreen mode Exit fullscreen mode

Now we've addressed every issue that's been pointed out except for one!

It would be nice if when we used the getProperty function that the result would be the type of the property on the original object.

For example I would expect that:

let age = getProperty({name: 'Dan', age: 32, admin: true}, 'age')
Enter fullscreen mode Exit fullscreen mode

Would actually return a type number and not 'string' | 'number' | 'boolean'.

In order to do that, we can add a second type parameter to our generic called K which can extends the keyof T - meaning that K must be a type in the key of T.

Like this:

type GetProperty = <T extends Record<string, unknown>, K extends keyof T>
  (obj: T, propertyName: keyof T) => typeof obj[keyof T]
Enter fullscreen mode Exit fullscreen mode

Ok, so this hasn't really changed anything yet. We have to actually make use of K!

We want the second argument to this function to be K and the return to be T[K], like this:

type GetProperty = <T extends Record<string, unknown>, K extends keyof T>
  (obj: T, propertyName: K) => T[K]
Enter fullscreen mode Exit fullscreen mode

So now when we use this function it has none of the issues discussed above, plus we get a return type that's been narrowed to it's most specific type (like number for example):

type GetProperty = <T extends Record<string, unknown>, K extends keyof T>
  (obj: T, propertyName: K) => T[K]

const getProperty: GetProperty = (obj, propertyName) => {
  return obj[propertyName];
}

// works as it did above but now, type is a number!!
let age = getProperty({name: 'Dan', age: 32, admin: true}, 'age')
Enter fullscreen mode Exit fullscreen mode

Conclusion

I've been on a mission to level up my competence in TypeScript lately. So learn and practice something new every morning before work (doing the #100DaysOfCode challenge).

But sharing this journey in public and posing questions like the original Tweet it's really helped bring a lot of depth to my learning.

I honestly thought that the answer to my question was an easy one, and it turned out to have a way deeper answer than I anticipated.

I hope you enjoyed this little journey that I went on, and maybe even deepened your understanding of TypeScript too!

I couldn't have written this article alone so kudos to these gems:


Like what you read? Want to support me?

A cup of coffee goes a long way 🙏

Why not buy me one? https://ko-fi.com/danjfletcher

Oldest comments (0)