TypeScript Done Wrong

TypeScript Done Wrong

by author Fernando Doglio

Writing TypeScript code feels great because you're protected by the compiler. Every time you transpile the code, it gets checked for any and all potential mistakes you've made. It's literally making you a better developer by helping you avoid simple bugs. That's amazing!

However, if you're not careful enough, you can still force those mistakes and introduce bugs by incorrectly using some of the language's features. That's right buddy, TypeScript can be a double-sided blade if you're not careful! So let's check out some mistakes you're probably making right now.

Overusing the any type

This one is a classic, especially for newcomers to the language, because they see it as a way of saying "I have no idea what's going to be inside this variable, so just let me use it alright?".

That happens because we think type declaration has to be done when you declare the variable and at that time, you may not know exactly the shape of the data you'll be dealing with. This is especially true if you're dealing with returned information from outside resources such as an API call, or perhaps after parsing a JSON string. But the key to solving this problem is not using the wildcard type any, instead is to understand that you can let TypeScript know that you don't know the type yet but you will soon.

Imagine, if you will, the following very silly example:

let something: any;
let condition = true;

if(condition) {
  something = JSON.parse('{ "name": "Fernando Doglio", "age": 37 }');
} else {
  something = JSON.parse('{ "race": "dog", "number_of_legs": 4, "sex": "Male"}')
}

console.log(something)

We could be dealing with the string being parsed coming from a file read or from user input. We don't really know the same it'll take, we just know that sometimes it'll be talking about a person and others it'll talk about an animal. So the shape of something will be different depending on some logical condition. This is wrong. Why? Because now we can't do any proper checks with something. If we wanted to reference one of its properties, it would be of type any by default, so really, any type of logic depending on something would automatically have to avoid type checking. We've created a cascade effect that is nullifying TypeScript's main feature.

Feel good about yourself already?

So instead of relying on the pesky any type, you can define something to be of type unknown which is kind of the same, but not exactly.

By defining your variable with that type, you're telling TypeScript that you don't know the type at that precise moment but that you will in a second. So instead, you could re-write the above code like this:

type  Human = {
  name: string;
  age: number;
}

type Animal = {
  race: string;
  number_of_legs: number;
  sex: 'Male' | 'Female';
}

function parseit(str: string): unknown {
  return JSON.parse(str);
}

let something;
let condition = true;

if(condition) {
  something = parseit('{ "name": "Fernando Doglio", "age": 37 }') as Human;
  console.log("This is a person, named: ", something.name)
} else {
  something = parseit('{ "race": "dog", "number_of_legs": 4, "sex": "Male"}') as Animal;
  console.log("This is a ", something.race)
}

Key changes on the code:

  • I've moved the parsing into a separate function that returns an unknown type.
  • I've defined 2 different types (Human and Animal). They're both potential types for something but we just don't know which ones until the code runs.
  • I've declared something without a type, this is key.
  • The logic of how you use something depending on its type needs to be inside each logical branch (i.e inside each block of the if statement).

With that out of the way, you can see how we've used the same variable to host 2 very different types of objects. This allows us to write code that is not type-checked during compilation (because it can't be checked then) but it does get checked during runtime.

Using the Function type instead of specifying the signature

Another very common situation is when dealing with closures or callbacks. We have to define a type for them, but using Function as that type is not enough. If we do that, then TS assumes we're dealing with a function that accepts any parameter and returns any as a result. Essentially we're back in the previous example, having completely obliterated the main benefit of TS.

In other words, you could write code like this:

function myMainFN(callback: Function) {
  let date = new Date();

  callback(date, "random string", 131412);
  let i = callback("this other string");
  console.log(i, date, callback(123125091))
}

Notice how I'm calling callback in 3 different ways. I'm even declaring a variable with the return of one of those calls. Care to guess the type of i ? That's right, it'll be any. Instead, take the time to understand the signature of the functions you'll want to allow here. The callback is probably meant to receive some very specific parameters and to return a very specific type. So define it damn it!

type myCallback = (error: Error|null, results: string) => void;

function myMainFN2(callback: myCallback) {
  let date = new Date();

  //your logic here...

  if(error) { //valid uses of callback
    callback(new Error("Something went terribly wrong, abort, abort!"), "");
  } else {
    callback(null, "This is fine");
  }


  //callback(date, "random string", 131412); - invalid because `date` is the wrong type and it also has 1 extra parameter
  //let i = callback("this other string"); - invalid because it's missing the first parameter 
  //console.log(i, date, callback(123125091)) - invalid because we're calling it with the wrong parameter
}

The first thing I did was I declared the callback's type: myCallback. I'm declaring it as a function that accepts 2 parameters, the first one potentially being null since I'm going with the "Error-first" pattern for this callback. And the second one being just a string. Suddenly all my previous calls to the callback are invalid and TS can tell me that. Fantastic! By defining the signature of the callback function I'm able to write code that properly relies on it and it's safe to do so, and I've also managed to document the way anyone using my function needs to structure their callback function. Win-Win!

Open Source Session Replay

Debugging a web application in production may be challenging and time-consuming. OpenReplay is an Open-source alternative to FullStory, LogRocket and Hotjar. It allows you to monitor and replay everything your users do and shows how your app behaves for every issue. It’s like having your browser’s inspector open while looking over your user’s shoulder. OpenReplay is the only open-source alternative currently available.

OpenReplay

Happy debugging, for modern frontend teams - Start monitoring your web app for free.

Assuming that object literals are inferred types

TypeScript has this very interesting feature called "type inferring" in which you're allowed to skip type declaration in some situations, and given the type of data you're using, it'll infer it for you. For example:

let myValue = 2;
let mySecondValue = "This is a string";
let result = 2 - mySecondValue;

That code would work, just like that, in JavaScript and it would return a value. It wouldn't make any sense, because the actual operation makes no sense so you'd get a NaN as a result. But still, the interpreter would run it. However, the second you throw that code at TS, it'll scream at you because it has already inferred that myValue is of type number and mySecondValue is of type string and you can't subtract a string from a number it's that simple!

Now, this works great with basic types, but not so much with object literals. With them, you can make mistakes without even knowing. How many times have you forgotten about writing a property? It happens, and TS will let you know about it, but only when you try to force a type into it, which is usually when you either pass it as a function parameter, or when you try to make it interact with other objects that DO have a type. Let's look at a quick example:

type  Human = {
  name: string;
  age: number;
}

type Animal = {
  race: string;
  number_of_legs: number;
  sex: 'Male' | 'Female';
  owner: Human;
}

let me = {
  name: "Fernando Doglio",
  age: 37
}

let myDog = {
  race: "Dog",
  number_of_legs: 4,
  sex: "Male",
}

Alright, so we've defined 2 object literals that somewhat follow the structure of both types Human and Animal but of course, just because they look like them doesn't mean TS will assume you're wanting them to follow their structure. This is syntactically valid code and we have no errors so far. Let's move on with the example:

function whosYourOwner(a: Animal): string|null {
  if(a.owner) {
    return a.owner.name
  }
  return null;
}


whosYourOwner(myDog) //BOOM

And here is where the problems begin. The call to whosYourOwner will not work, because you now have 2 issues with your objects. Can you guess what they are? We're coercing our myDog object literal into an Animal, and we're seeing that:

  • On one hand, we're missing the owner attribute. Whoops! No worries though, we'll fix it quickly.
  • But even if we do, we also have a problem with the sex property, because string (which is the inferred type for this property) is not going to be compatible enum 'Male'|'Female'.

We're in trouble. However, the solution is very simple: assign a type of your object literal during declaration.

let myDog: Animal = {
  race: "Dog",
  number_of_legs: 4,
  sex: "Male",
};

Now 2 things will happen:

  • First you'll hear from TS right there and now that you're missing the owner property. It's not marked as optional on the type declaration, so it'll be required here.
  • The value of Male will be a valid option for the enum on sex.

In other words, type inferrence:

  • For basic types: good
  • For complex types: not good

Numeric enums are bad - string enums good

This one is quick, but it can save you a huge headache and hours of debugging. How do you declare your enums in TS? I mean, the whole point of them is to forget about their values, and directly use the enum properties, if you will. Right? So let's pretend we're creating an enum around your residency status (i.e if you're a citizen, a resident in your country or if you're working with a Visa):

enum ResidencyStatus {
  Resident,
  Citizen,
  VisaWorker,
}

let myStatus: ResidencyStatus;

myStatus = ResidencyStatus.Resident;

console.log(myStatus)

The above code would work, and what would be the output of that last line? That's right, it would be 0, that's because TS is gracious enough to assign a value automatically for us when we don't specify one. This is great and it shows how irrelevant the actual values are inside the enums. What's the problem with this? Easy, the following code is completely valid for TS as well:

let myStatus: ResidencyStatus;

myStatus = 124124;

console.log(myStatus)

See the problem? I've defined a variable of type ResidencyStatus which by definition, should let me limit the values I assign to it, but I can still assign any numeric value and TS won't know the difference. Can we solve this? YES we can, and it's not that complicated, we just need to assign string values to our elements inside the enum, and suddenly TS will become a lot more reserved about the type of value we assign to our variable:

enum ResidencyStatus {
  Resident="res",
  Citizen="cit",
  VisaWorker="visa",
}

let myStatus: ResidencyStatus;

myStatus = "123";

console.log(myStatus)

Now that code won't work, because TS will not like you assigning "123" to your property. Problem solved!

Private class attributes through the private keyword

Finally, this one caught me by surprise when working with TS myself. I was never a big fan of classes being added to JavaScript myself, considering the Prototypal Inheritance model worked just fine and to be honest, classes are just syntactic sugar around it at this point. However, when TS came into my life, I started seeing how they worked hard on actually implementing a more complete OOP model, so that sounded appealing and decided to give it a try. But then I hit this wall and I was again, a bit pissed.

So what's the problem then? As it turns out, the private keyword, which you'd use to define a private property in your class, does not make said property that private. What? you ask. Yes I know, let me explain. This is how one would normally go about declaring a private property:


class Person {
  private name: string;
  private age: number;

  constructor(n: string, a: number) {
    this.name = n;
    this.age = a;
  }

  get Name():string {
    return this.name;
  }
  set Name(newName: string) {
    this.name = newName;
  }

  get Age():number {
    return this.age;
  }

  set Age(newAge: number) {
    this.age = newAge;
  }

}

let oMySelf = new Person("Fernando Doglio", 37);
oMySelf.Age = 38;

We're OK with this, right? We've declared both our properties as private, so we can fully control how and when they get updated. You can't really do oMySelf.name="someone else", TS won't let you.

Of course, not until you do something like this:

for ( let prop in oMySelf) {
  console.log(Object.getOwnPropertyDescriptor(oMySelf, prop))
}

Or something like this one (this one is personally my favorite):

console.log((oMySelf as any).name)

Since the private keyword only works during compilation, the runtime won't be able to restrict you. So the in keyword can iterate over the actual list of private properties and through Object.getOwnPropertyDescriptor you can get the values and more information even, from those supposedly protected and secure variables. And don't get me started on casting the object to any, you can do anything with the result of that!

Alright but that is just about reading private data, which could be a problem, but it's not as bad as modifying it. Right? That's still protected, isn't it? Nope, let's look at Object.assign for a second. And to test it properly, let's assign a private property that has no getter or setter to our Person class:

class Person {
  private name: string;
  private age: number;
  private secret: number;

  constructor(n: string, a: number, s: number) {
    this.name = n;
    this.age = a;
    this.secret = s;
  }
  ....

And how, let's create 2 objects of the same class with completely random secret values:

let oMySelf = new Person("Fernando Doglio", 37, Math.random());

let someoneElse = new Person("Other Person", 1000, Math.random());

We can see they're different through our any magic trick:

console.log((someoneElse as any).secret)
console.log((oMySelf as any).secret)

That should print 2 random floating-point numbers. However, if we add a single line before those console.log:

Object.assign(oMySelf, someoneElse)
console.log((someoneElse as any).secret)
console.log((oMySelf as any).secret)

The secret value is now the same. In fact, everything is the same between both objects. We've effectively overwritten all the private values of oMySelf with the ones on someoneElse.

Can we prevent this? Yes we can! We just need to use JavaScript's private property notation, instead of TypeScript's:


class Person {
  #name: string;
  #age: number;
  #secret: number;

  constructor(n: string, a: number, s: number) {
    this.#name = n;
    this.#age = a;
    this.#secret = s;
  }

  get Name():string {
    return this.#name;
  }
  set Name(newName: string) {
    this.#name = newName;
  }

  get Age():number {
    return this.#age;
  }

  set Age(newAge: number) {
    this.#age = newAge;
  }

}

It looks terrible, I know, but this is going to prevent every single one of our problems:

  • in won't iterate over our private properties, so we can't really get their names.
  • Object.Assign will ignore private properties, so they won't get overwritten.
  • And trying to cast our object to any won't change a thing, since TS will complain that you're trying to access a private property outside the class declaration.

All problems solved!

Conclusion

TypeScript is fun and if used correctly, a very powerful tool that can help you avoid lots of mistakes! But it's not perfect and if you're not careful and you don't really understand both the language itself and JavaScript, then you'll run into potential headache-inducing problems or even worse: security issues that could cause catastrophic failure on your app.