In this article, we’ll discuss TypeScript, its benefits, and how to introduce it to a legacy piece of JavaScript code.
By the end this article you’ll learn:
- What TypeScript is and what its benefits and tradeoffs are
- How to get started with TypeScript with a legacy JavaScript codebase
- How to use type annotations in TypeScript
- How to use nullability checks in TypeScript
- Next steps for further improving TypeScript code
What is TypeScript?
So, what is TypeScript and why should you use it?
Put simply, TypeScript is a superset of JavaScript. Think of it as JavaScript with additional annotations and static type checking.
TypeScript transpiles down to JavaScript, so any browser that runs JavaScript can run code written in TypeScript. TypeScript can also target older versions of JavaScript. This lets you use modern JavaScript features like classes, arrow functions, let/const
, and template strings while targeting browsers that don’t yet support these things.
Additionally, TypeScript’s static checking makes entire classes of defects impossible, which is something I feel very strongly about.
With that brief introduction, let’s meet the app we’ll be migrating to TypeScript.
The Sample Application
We’ll be working with a simple JavaScript application that we’ll migrate to TypeScript.
The code is available on GitHub in its initial JavaScript state (with a few bugs) and its finished TypeScript state. If you’d like to play with the final fixed version in your browser, it is available online.
The app is a simple test case manager where the user types in the name of a test case and adds it to the list. Test cases can then be marked as passed, failed, or deleted.
This is an intentionally simple and intentionally buggy app. It doesn’t use any JavaScript frameworks or even any JavaScript libraries – not even JQuery or Underscore / Lodash.
The app does use Bootstrap v4 with Bootswatch’s Darkly theme in order to keep the HTML simple with a clean UI for this article.
Existing HTML
While our focus is going to be on the JavaScript, there are a few things in the HTML to be aware of:
Specifically, let’s look at a few lines:
- Line 7 imports our main JavaScript code
- Line 22 references
addTestCase
defined in our JavaScript code. - Line 27 –
lblNoTestCases
is a label that is shown if no test cases exist - Line 28 –
listTestCases
is a placeholder for the test case UI elements
Startup JavaScript Code
With that aside, let’s look at the existing code in a few chunks:
Here we define a TestCase
class that serves as our primary (and only) entity in this application. We have a collection of testCases
defined in line 1 that holds the current state. At line 20, we add a startup event handler that generates the initial application data and calls out to the function to update the test cases.
Pretty simple, though it does contain at least one bug (see if you can find it before I point it out later).
Rendering JavaScript Code
Now, let’s look at our list rendering code. It’s not pretty since we’re not using a templating engine or a fancy single page application framework like Angular, Vue, or React.
The code here is relatively self-explanatory and clears out the list of items, then adds each item to the list. I never said it was efficient, but it works for a demo.
Like the last, this chunk contains at least one bug.
Event Handling JavaScript Code
The final chunk of code handles events from the user.
This specifically handles button clicks and adding items to the list.
And, again, there’s at least one bug in this chunk.
What’s Wrong with the Code?
So, what’s wrong here? Well, I’ve observed the following problems:
- It’s impossible to fail or delete the initial test data.
- It’s impossible to fail any added test
- If you could delete all items, the add item label wouldn’t show up
Where the bugs are isn’t the point. The point is: each one of these bugs would have been caught by TypeScript.
So, with that introduction, let’s start converting this to TypeScript. In the process, we’ll be forced to fix each one of these defects and wind up with code that can’t break in the same way again.
Installing TypeScript
If you have not already installed TypeScript, you will need to install Node Package Manager (NPM) before you get started. I recommend installing the Long Term Support (LTS) version, but your needs may be different.
Once NPM is installed, go to your command line and execute the following command: npm i -g typescript
This will i nstall TypeScript g lobally on your machine and allow you to use tsc
, the T ype S cript C ompiler. As you can see, although the term for converting TypeScript code to JavaScript is transpiling, people tend to say compiler and compilation. Just be aware that you may see it either way – including in this article.
With this complete, you now have everything you need in order to work with TypeScript. You don’t need a specific editor to work with TypeScript, so use whatever you like. I prefer to work with WebStorm when working with TypeScript code, but VS Code is a very popular (and free) alternative.
Next, we’ll get set up with using TypeScript in our project.
Compiling our Project as a TypeScript Project
Initializing TypeScript
Open a command line and navigate into your project directory, then run the following:
tsc --init
You should get a message indicating that tsconfig.json
was created.
You can open up the file and take a look if you want. Most of this file is commented out, but I actually love that. TypeScript gives you a good configuration file that tells you all the things you can add in or customize.
Now, if you navigate up to the project directory and run tsc
you should see TypeScript displaying a number of errors related to your file:
These issues are all valid concerns, but for the moment, let’s disable some by editing the tsconfig.json file and setting "strict": false,
.
Now, if you try to compile, you’ll get a much smaller subset of errors. Most of them seem to be around the TestCase
class, so let’s take a look at that now.
Type Annotations
Most of the errors seem to be around isPassing
and id
not being defined on that class. That makes sense since we were using JavaScript’s innate ability to dynamically define properties. Since we’re using TypeScript’s checking, we’ll need to define those fields now:
Lines 8-10 are new here and define the missing fields. Note that we have type annotation syntax here in the : string
, : boolean
, and : number
definitions.
Type Assertions
Next, we’ll address an issue in the addTestCase
method. Here, TypeScript is complaining that HTMLElement
doesn’t have a value
field. True, but the actual element we’re pulling is a text box, which shows up as an HTMLInputElement
. Because of this, we can add a type assertion to tell the compiler that the element is a more specific type.
The modified code looks like this:
const textBox = <HTMLInputElement>document.getElementById('txtTestName');
_ Important Note: TypeScript’s checks are at compile time, not in the actual runtime code. The concept here is to identify bugs at compile time and leave the runtime code unmodified._
Correcting Bad Code
TSC
also is complaining about some of our for
loops, since we were cheating a little and omitting var
syntax for these loops. TypeScript won’t let us cheat anymore, so let’s fix those in updateTestCases
and findTestCaseById
by putting a const
statement in front of the declaration like so:
function findTestCaseById(id) {
for (const testcase of this.testCases) {
if (testcase.id === id) return testcase;
}
return null;
}
Fixing the Bugs
Now, by my count, there are two more compilation issues to take care of. Both of these are related to bugs I listed earlier with our JavaScript code. TypeScript won’t allow us to get away with this, thankfully, so let’s get those sorted out.
First of all, we call to showAddItemsPrompt
in updateTestCases
, but our method is called showAddItemPrompt
. This is an obvious issue, and one that could conceivably be caused either by a typo or renaming an existing method but missing a reference. This is easily changed by making sure the names match.
Secondly, failTestCase
declares a variable called testCase
and then tries to reference it as testcase
, which is just never going to work. This is an easy fix where we can make sure the names are consistent.
Referencing our Compiled Code
And, with that, running tsc
results in no output – that mean our code compiled without issue!
On top of that, because Logic.ts will automatically transpile into Logic.js
, the file our index.html
is referencing anyway, that means that we don’t even have to update our HTML.
And so, if we run the application, we can see that we can fail and delete tests again:
But wait, weren’t there three errors in the code? TypeScript only found two!
Well, yes, but we haven’t told TypeScript enough to find the third one yet. Let’s fix that by re-enabling strict mode.
Strict Mode
Going back into tsconfig.json
, set strict
to true
.
This should yield about 16 errors during compile. The vast majority are no impicit any, or TypeScript complaining that it doesn’t know what type things are. Going through and fixing that is somewhat straightforward so I won’t walk through it, but feel free to check my finished result if you get lost.
Beyond that, we see a few instances where TypeScript points out that things could be null. These involve fetching HTML Elements from the page and can be resolved via type assertions:
const list = <HTMLElement>document.getElementById('listTestCases');
The type assertions are acceptable here because we are explicitly choosing to accept the risk of a HTML element’s ID changing causing errors instead of trying to somehow make the app function without required user interface elements. In some cases, the correct choice will be to do a null check, but the extra complexity wasn’t worth it in a case where failing early is likely better for maintainability.
Removing Global State
This leaves us with 5 remaining errors, all of the same type:
'this' implicitly has type 'any' because it does not have a type annotation.
TypeScript is letting us know that it is not amused by our use of this to refer to items in the global scope. In order to fix this (no pun intended), I’m going to wrap our state management logic into a new class:
This generates a number of compiler errors as things now need to refer to methods on the testManager
instance or pass in a testManager
to other members.
This also exposes a few new problems, including that bug I’ve alluded to a few times.
Specifically, when we create the test data in buildInitialData
we’re setting the id
to '1'
instead of 1
. To be more explicit, id
is a string
and not a number
, meaning it will fail any ===
check (though ==
checks will pass still). Changing the property initializer to use the number fixes the problem.
_ Note: This problem also would have been caught without extracting a class if we had declared type assertions around the testcases
array earlier._
The remaining errors all have to do with handling the results of findTestCaseById
which can return either a TestCase
or null
in it’s current form.
In TypeScript, this return type can be written explicitly as TestCase | null
. We could handle this by throwing an exception instead of returning null if no test case was found, but instead we should probably heed TypeScript’s advice and add null checks.
I’ve glossed over many details, but if you’re confused on something or want to see the final code, it is available in my GitHub repository.
Benefiting from TypeScript
Now, when we run the application, the code works perfectly
Not only that, the compiler itself makes sure that the errors we encountered will never be possible again (if we keep playing by the rules, anyway).
Additionally, TypeScript helped us gracefully handle potential errors down the road by forcing us to think about potentially null values.
Next Steps
If you’re interested in getting more in-depth with TypeScript, stay tuned as I intend to cover more topics of note including:
- Linting to find additional issues
- Testing TypeScript with Jest
- Automatically formatting code with Prettier
- Bundling files together
- Using NPM and WebPack to manage complex build processes
If you’d like to start with a fresh project already set up for these things, I recommend you check out Christoffer Noring’s TypeScript Playground repository on GitHub.
Closing Thoughts
There’s been a recent surge of people attacking TypeScript for getting in the way, obfuscating JavaScript, being unnecessary, etc. And sure, maybe TypeScript is overkill for an app of this size, but here’s where I stand on things:
TypeScript is essentially a giant safety net you can use when building JavaScript code. Yes, there’s effort in setting up that safety net, and no, you probably don’t need it for trivial things, but if you’re working on a large project without sufficient test coverage, you need some form of safety net or you’re going to be passing off quality issues to your users.
To my eyes, TypeScript is an incredibly valuable safety net that supports existing and future unit tests and allows QA to focus on business logic errors and usability instead of programming mistakes.
I’ve taken a large JavaScript application and migrated it to TypeScript before to great effect. In the process, I resolved roughly 10 – 20 open bug tickets because TypeScript made the errors glaringly obvious and impossible to ignore.
Even better, this process made the types of errors that had occurred anytime the app was touched impossible to recur.
So, the question is this: What’s your safety net? Are you really willing to let language preferences pass on defects you might miss to your end users?
The post Migrating to TypeScript appeared first on Kill All Defects.
Top comments (6)
You should probably use
as
instead:It plays better with
*.tsx
files. And doesn't look like a generic type argument. Seen people mix those two up.I had the feeling, while TypeScript is rather close to JavaScript, that I write code differently in a statically typed language than in a dynamically typed one. Idiomatic TS isn't idiomatic JS, I guess. :D
Writing JS is like throwing untagged unions around everywhere, so I could imagine that typing that code with TS later could become some kind of hassle.
Anyway, I added this article to my reading list to get a better feeling for how big of a problem this really is. Thanks for writing it! :)
This was a great article, but I don't think typing a variable which the compiler can guess its a good idea...
My rule is that a functions' input and output should always be typed, even if it can be guessed. This to avoid mistakes when you refactor that function.
And to type anything that the compiler can't guess, like
getElementById
or JSON.parse. Often type it tounknown
and write type guards. Anything else shouldn't be typed. You should never need to type already known types tounknown
and back to a different type. You should never need to type toany
except as a temporary fix for old code.Can you be specific about which lines this comment pertains to in the final repository?
Thanks a lot for the post. It helped me in understanding the transition to Typescript and doing further steps for myself.