I used to hate Typescript. Here’s why I love it now

While Typescript has a high rate of adoption and it is generally very well-regarded in the community, there are still many developers that – despite Typescripts many advantages – still prefer Javascript.

I myself had a preference for Javascript well into 2021, before I finally succumbed to the allures of Typescript, and decided to give it a proper chance.

Today, I’m here to convince you to start using Typescript and why it’s better than Javascript. I’m here to help you understand why Typescript is good, exactly which parts about it are good, and why you’re going to love it too.

Ever felt that Typescript was unnecessarily complicated and you really dislike this new necessity for writing complex Types?

It may just sound like you’re using it wrong… So, this post is for you!

Let’s dive in!

Small beginnings

At first, I found it extremely confusing. I mean I understood that Typescript is fundamentally Typescript and you should, atleast theoretically, be able to start using Typescript, by changing the file extension from .js to .ts.

So I set off on my journey, decided I would take a few files first and gradually do more and more as I became more familiar.

As far as hindsight goes, I think that was a pretty healthy decision.

That being said, it wasn’t an easy one. Immediately after converting my first file I saw a ton of errors. require not being the primary way to import files and TS yelling at me to change to import, implicit function parameters and return types, implicit objects types when looping over things.

Admittedly, I probably used an overly restrictive typescript configuration, and had something close to strict: true enabled from day one. Big mistake.

I quickly started dropping : any everywhere, creating interfaces and types for everything left and right to start (manually) type annotating everything.

This bit is important, because as you will later learn, that isn’t necessarily the smartest, fastest or even most accurate way to go about it.

Slowly, but surely, I made it past those first files. I even annotated some function arguments as number or string here and there, but for the most part I was just slapping any and refactoring to please the compiler.

In short; I wasn’t even close to optimizing my returns on using Typescript.

Getting my feet wet(ter)

Over the next 3-6 months I gradually improved my Typescript knowledge and learned about union types, Pick, Except, Partial, as well as an interesting library “Typegoose” that has been a stable in our conversion from Mongoose to a Typescript-based implementation of mongoose.

This forced me to start thinking about data objects and having a solid grasp of what I was sending (and expecting) across the application, and what that data look like.

At this point everything began crumbling as I was refactoring a ~4/5 year old codebase that had mostly been focused on “move fast and break things” rather than robustness.

As a side note, I still believe in speed, but with Typescript I feel robustness is automatically increased by a ton, without sacrificing speed, but more on that in another post.

It became clear that a lot of unnecessary data was passed around, resulting in a ton of checks that could have been avoided with tighter control – and understanding really – of the data being passed.

As I began working more closely with Typegoose, I started annotating everything. First I refactored from mongoose to typegoose, which conveniently creates entities that understands the datamodel you built that entity with.

In order words you generate an interface as you define the schema, which means you can use that interface across the application plus the added benefit of inferred type annotations when working with that entity.

Removing complexity

Remember when I said I manually type notated everything? During my initial refactor from JS to TS I had type annotated most things, even objects, params and all instances of looping something.

Yeah. It was a mess.

But armed with my new knowledge of Typescript and the interfaces provided by Typegoose, I was able to greatly improve how everything looked, starting now.

Take an example of my code looking something like this:

import { IUser } from 'src/user/user.interface.ts';
import { userService } 'from src/user/user.service.ts';

function findUser(userId: string): Promise<IUser|void> {
    userService.findById(userId).then((user: IUser) => {
        if (user.active === true) {
            return user
        }
    })
    .catch((err: any) => {
        console.log(err);
        // Do something else with error
    }
}

In this relatively contrived example, we have some things going for it. I’m using a service-abstraction, AND it’s actually using an interface IUser to let us know what return type we’re working with.

Returning void can leave handling the error up to the calling method which isn’t great, but doable.

Now, what are some things I don’t like about that example?

Well, first of all, I don’t like the style of the function definition because it is less reusable than the alternative style we’re using now export const someFunction = () => { ... }, but more on that in a bit.

The things I don’t like about the above example, is the two implications in particular that:

  • We are defining and maintaining an interface IUser
  • We are not using inferred type annotations from the userService.

This could now be handled, using a bit of Typegoose magic, that allows it to look at the entity definition which automatically ships an interface when calling mongoose methods on it!

So let’s rewrite the example, to something I like more:

import { userService } 'from src/user/user.service.ts';

export const findUser = async (userId: string) => {
    const foundUser = userService.model.findOne(userId);
    
    if (!foundUser.active) return; // Handle any sort of error here
}

Ahhh, much better!

While this example doesn’t do exactly the same thing – and you are fair to argue that’s an unfair comparison – it still accomplishes the same goal: return a user, or some other value in case of an error.

The important wins here are:

  • We rely much more on inferred type annotations, both for the return type and the object type itself.
  • We can entirely omit the (extra) interface IUser
  • The method is a lot shorter!

And yes, if we’re being fair, that last win could have happened without Javascript as well.

But cleaning up Typescript because we can trust Typescript to infer the correct types (return and object) makes our method a lot more robust.

Once we decide to call this from elsewhere in our application, Typescript knows exactly how to work with the return object, as well as which potential undefined that we would need to test for.

Which brings me to my next point!

Editor autocompletion

I have always believed in “choosing the right tool for the task”, but simultaneously never believed that I would write as much as a single line about auto completion. I always thought of it as a “nice to have” feature, and while I’m sure that’s technically true, the cognitive load that it helps reduce by knowign everythign for me, just adds up.

Tools are some of the best assets for us developers and it’s important that we use them right. Sure, choosing the right tool for the task is a big part of that, but making sure that tool works as intended, is a big deal too.

Imagine we need to call the example from above, somewhere in our application, after returning from findUser method, it knows exactly what object we’re working with and we can just hit . and see what options are available. We don’t need to remember, if the attribute name was “user_role”, “userRole”, or just “role”.

TS remembers for us!

If we look back at our contrived example and envision how a usecase might look, it could be something like this:

import { findUser } from './findUser.ts';

export const someFunction = async () => {
    const userId = 'some-id-here';
    const emailContents = { message: 'hello world!' };

    const user = await findUser(userId);

    user.sendEmail(emailContents); 
}

Again, a bit of a contrived example, but bear with me!

If we don’t do anything more than the above, we would get a compiler error! Did you get why?

When we attempt to call sendEmail TS would be complaining that user is potentially undefined, which – of course – we can’t call sendEmail on.

This may seem annoying, if you’re using snippets like this in your Javascript codebase.

Well, hopefully I can convince you otherwise in the next section!

Compiler errors are your friend!

What?

Yes you got that one right.

To some extend TS can alleviate the need for – or entirely replace – testing! It can do that, because Typescript needs to be compiled to JS first.

Essentially, when we do testing what we try to achieve is reproduce-ability and fast feedback, in order to optimize developer experience so that we can build robust applications, fast.

And compilers – to some extent – do the same thing!

Looking back at our example from before, one way to test it might be to try a few different variations of userIds; a valid string, an invalid string, an array and a number.

These are all great cases to test, so we can handle the input for that function accordingly. And, admittedly, depending on where the input comes from we may still need to. This is often the case with user data and why we need to sanitize user inputs.

But, for arguments sake, let’s assume this input has already been sanitized and we’re only getting exactly what we expect; a string.

We no longer need to write any tests for this! Why not?

Well, the compiler has our back. If at any point we try to call that findUser method with any argument except a single string, TS will let us know by throwing a compiler error.

Typescript is simple in that sense. With great modularity and seperation of concerns we can leave a lot of responsibility with the compiler, and start shipping with confidence.

In other words:

Fix the compiler errors, and you’re good to go!

Ways you can ease into it, and get started

By now, I’m confident you love Typescript and can’t wait to get started.

If, for some crazy reason you’re still not quite there, please let me know in the comment section what I could have done to persuade you.

I want you to drink the kool aid 😉

So how can you get started with it?

There are of course many approaches to take, but there is one that rule them all: just start using it.

Yes really, it’s that simple.

Sure there are multiple ways to do that, and I’m sure some are better than others, but in your shoes I would consider the following

  • Got any medium or large Javascript projects? Try converting (parts of) it to Typescript! Do it incrementally and build your confidence as you go along.
  • Starting something new? Try setting it up in Typescript instead of Javascript, just to get your feet wet.

I know that Angular and React have come a long way with Typescript support, making it very easy to start here.

If you’re starting a conversion project, you can even start with strict: false in order to make the TS options “opt-in”.

For arguments sake I do have to mention that if you’re working on a team, putting strict: false may mean very little change, because nobody is forced to adopt Typescript. In the real world, enforcing patterns is always more helpful than recommending them.

Start having some fun with it!

Learning new things is fun, and I’m sure you will enjoy diving into Typescript and the many layers it has, as soon as you get started.

Start loving Typescript too

I write a newsletter with no more than one mail every week or so. Why not listen in, to some of the many meaningful and wonderous things happening in Typescript land, directly in your inbox?

Go from Typescript-zero to hero, in no time by following the tips I provide, by signing up below