Can you spot the Type Error?
function getProperty<T>(obj: T, propertyName: string) {
return obj[propertyName];
}
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')
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];
}
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];
}
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
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
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 useunknown
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];
}
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'
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')
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')
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:
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] }
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];
}
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')
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')
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]
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]
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')
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:
- https://twitter.com/Nartc1410 (for the insights and poking holes in my logic)
- https://twitter.com/bitknight (for proof reading, insights and suggestions)
- https://twitter.com/rgolea (suggesting solutions)
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
Top comments (0)