After many years of doing "regular" JavaScript, I've recently (finally) had the chance to get my feet wet in TypeScript. Despite some people boldly telling me that "I'd pick it up in 5 minutes"... I knew better.
For the most part it is fast-and-easy to pick up. But switching to a new paradigm always gets hung up around the edge cases. TypeScript has been no exception to this.
I already wrote two long posts about the hurdles I had to jump through just to get React/TS to define default prop values under the same conventions that are common (and easy) with React/JS. My latest conundrum has to do with the handling of object keys.
The Problem
When I'm using JavaScript, I frequently have to deal with various objects. If you've done any JS development, you know I'm not talking about "objects" in the same way that, say, a Java developer talks about "objects". The majority of JS objects that I seem to encounter are more equivalent to hashmaps - or, on a more theoretical level, tuples.
For example, it's quite common for me to have two objects that might look like this:
const user1 = {
name: 'Joe',
city: 'New York',
age: 40,
isManagement: false,
};
const user2 = {
name: 'Mary',
city: 'New York',
age: 35,
isManagement: true,
};
Nothing too complex there, right? Those "objects" are just... data structures.
So let's now imagine that I often need to find what any two users have in common (if anything). Because my app requires this assessment frequently, I want to create a universal function that will accept any two objects and tell me which key values those objects have in common.
In JavaScript, I could quickly crank out a little utilitarian function like this:
const getEquivalentKeys = (object1: {}, object2 = {}) => {
let equivalentKeys = [];
Object.keys(object1).forEach(key => {
if (object1[key] === object2[key]) {
equivalentKeys.push(key);
}
});
return equivalentKeys;
}
[NOTE: I realize that this could be done even more efficiently with, say, a good .map()
function. But I think this is a bit clearer (meaning: more verbose) for the purposes of this illustration.]
With the function above, I can now do this:
console.log(getEquivalentKeys(user1, user2));
// logs: ['city']
And the function result tells me that user1
and user2
share a common city. Pretty dang simple, right??
So let's convert this to TypeScript:
const getEquivalentKeys = (object1: object, object2: object): Array<string> => {
let equivalentKeys = [] as Array<string>;
Object.keys(object1).forEach((key: string) => {
if (object1[key] === object2[key]) {
equivalentKeys.push(key);
}
});
return equivalentKeys;
}
This "looks" right to me, except... TS doesn't like it. Specifically, TS doesn't like this line:
if (object1[key] === object2[key]) {
TS says:
Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{}'.
Hmm...
To be clear, I know that I could easily use an interface to define the user
type and then declare it in the function signature. But I want this function to work on any objects. And I understand why TS is complaining about it - but I definitely don't like it. TS complains because it doesn't know what type is supposed to index a generic object
.
Wrestling With Generics
Having already done Java & C# development, it immediately struck me that this is a use-case for generics. So I tried this:
const getEquivalentKeys = <T1 extends object, T2 extends object>(object1: T1, object2: T2): Array<string> => {
let equivalentKeys = [] as Array<string>;
Object.keys(object1).forEach((key: string) => {
if (object1[key] === object2[key]) {
equivalentKeys.push(key);
}
});
return equivalentKeys;
}
But this leads to the same problem as the previous example. TS still doesn't know that type string
can be an index for {}
. And I understand why it complains - because this:
const getEquivalentKeys = <T1 extends object, T2 extends object>(object1: T1, object2: T2): Array<string> => {
Is functionally equivalent to this:
const getEquivalentKeys = (object1: object, object2: object): Array<string> => {
So I tried some more explicit casting, like so:
const getEquivalentKeys = <T1 extends object, T2 extends object>(object1: T1, object2: T2): Array<string> => {
let equivalentKeys = [] as Array<string>;
Object.keys(object1).forEach((key: string) => {
const key1 = key as keyof T1;
const key2 = key as keyof T2;
if (object1[key1] === object2[key2]) {
equivalentKeys.push(key);
}
});
return equivalentKeys;
}
Now TS complains about this line again:
if (object1[key1] === object2[key2]) {
This time, it says that:
This condition will always return 'false' since the types 'T1[keyof T1]' and 'T2[keyof T2]' have no overlap.
This is where I find myself screaming at my monitor:
Yes, they do have an overlap!!!
Sadly, my monitor just stares back at me in silence...
That being said, there is one quick-and-dirty way to make this work:
const getEquivalentKeys = <T1 extends any, T2 extends any>(object1: T1, object2: T2): Array<string> => {
let equivalentKeys = [] as Array<string>;
Object.keys(object1).forEach((key: string) => {
if (object1[key] === object2[key]) {
equivalentKeys.push(key);
}
});
return equivalentKeys;
}
Voila! TS has no more complaints. But even though TypeScript may not be complaining, I'm complaining - a lot. Because, by casting T1
and T2
as any
, it basically destroys any of the wonderful magic that we're supposed to get with TS. There's really no sense in using TS if I'm gonna start crafting functions like this, because anything could be passed into getEquivalentKeys()
and TS would be none the wiser.
Back to the drawing board...
Wrestling With Interfaces
Generally speaking, when you want to explicitly tell TS about the type of an object, you use interfaces. So that leads to this:
interface GenericObject {
[key: string]: any,
}
const getEquivalentKeys = (object1: GenericObject, object2: GenericObject): Array<string> => {
let equivalentKeys = [] as Array<string>;
Object.keys(object1).forEach((key: string) => {
if (object1[key] === object2[key]) {
equivalentKeys.push(key);
}
});
return equivalentKeys;
}
And... this works. As in, it does exactly what we'd expect it to do. It ensures that only objects will be passed into the function.
But I gotta be honest here - it really annoys the crap outta me. Maybe, in a few months, I won't care too much about this anymore. But right now, for some reason, it truly irks me to think that I have to tell TS that an object
can be indexed with a string
.
Explaining To The Compiler
In my first article in this series, the user @miketalbot had a wonderful comment (emphasis: mine):
I'm a dyed in the wool C# programmer and would love to be pulling across the great parts of that to the JS world with TypeScript. But yeah, not if I'm going to spend hours of my life trying to explain to a compiler my perfectly logical structure.
Well said, Mike. Well said.
Why Does This Bother Me??
One of the first things you learn about TS is that it's supposedly a superset of JavaScript. Now, I fully understand that, if you desire to truly leverage TS's strengths, there will be a lotta "base" JS code that the TS compiler won't like.
But referencing an object's value by key (a type:string key), is such a simple, basic, core part of JS that I'm baffled to think that I must create a special GenericObject
interface just to explain to the compiler that:
Yeah... this object can be indexed by a string.
I mean, that works. But if that's the way I'm supposed to do this it just makes me think:
Wait... what???
It's the same kinda annoyance I'd have if you told me that I have to explain to TS that a string
can contain letters and numbers and special characters.
Now that I've figured out how to get around it, I suppose it's just one of those things that you "get used to". Or... maybe there's some simple technique in TS that would allow me to get around this (without disabling TS's core strengths). But if that magical solution exists, my paltry googling skills have yet to uncover it.