Key Headaches in TypeScript

Adam Nathaniel Davis - Jun 27 '20 - - Dev Community

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,
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

[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']
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

This "looks" right to me, except... TS doesn't like it. Specifically, TS doesn't like this line:

if (object1[key] === object2[key]) {
Enter fullscreen mode Exit fullscreen mode

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.


Alt Text

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;
}
Enter fullscreen mode Exit fullscreen mode

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> => {
Enter fullscreen mode Exit fullscreen mode

Is functionally equivalent to this:

const getEquivalentKeys = (object1: object, object2: object): Array<string> => {
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Now TS complains about this line again:

if (object1[key1] === object2[key2]) {
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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...


Alt Text

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;
}
Enter fullscreen mode Exit fullscreen mode

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.


Alt Text

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.


Alt Text

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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player