A Step-by-Step TypeScript Tutorial for Beginners
You've probably heard of TypeScript — the language created and maintained by Microsoft that's had a huge impact on the Web, with many prominent projects embracing and migrating their code to TypeScript. TypeScript is a typed superset of JavaScript. In other words, it adds types to JavaScript — and hence the name. But why would you want these types? What benefits do they bring? And do you need to rewrite your entire codebase to take advantage of them? Those questions, and more, will be answered in this TypeScript tutorial for beginners.
We assume a basic knowledge of JavaScript and its tooling, but zero prior knowledge of TypeScript is required to follow along.
Some Erroneous JavaScript Code
To start with, let's look at some fairly standard plain JavaScript code that you might come across in any given codebase. It fetches some images from the Pexels API and inserts them to the DOM.
However, this code has a few typos in it that are going to cause problems. See if you can spot them:
const PEXELS_API_KEY = '...';async function fetchImages(searchTerm, perPage) { const result = await fetch(`https://api.pexels.com/v1/search?query=${searchTerm}&per_page=${perPage}`, { headers: { Authorization: PEXELS_API_KEY, } }); const data = await result.json(); const imagesContainer = document.qerySelector('#images-container'); for (const photo of data.photos) { const img = document.createElement('image'); img.src = photo.src.medium; imagesContainer.append(img); }}fetchImages('dogs', 5);fetchImages(5, 'cats');fetchImages('puppies');
Can you spot the issues in the above example? Of course, if you ran this code in a browser you'd immediately get errors, but by taking advantage of TypeScript we can get the errors quicker by having TypeScript spot those issues in our editor.
Shortening this feedback loop is valuable — and it gets more valuable as the size of your project grows. It's easy to spot errors in these 30 lines of code, but what if you're working in a codebase with thousands of lines? Would you spot any potential issues easily then?
Note: there's no need to obtain an API key from Pexels to follow along with this TypeScript tutorial. However, if you'd like to run the code, an API key is entirely free: you just need to sign up for an account and then generate one.
Running TypeScript from the Editor
Once upon a time, TypeScript required that all files be written as .ts
files. But these days, the onboarding ramp is smoother. You don't need a TypeScript file to write TypeScript code: instead, we can run TypeScript on any JavaScript file we fancy!
If you're a VS Code user (don't panic if you aren't — we'll get to you!), this will work out the box with no extra requirements. We can enable TypeScript's checking by adding this to the very top of our JavaScript file (it's important that it's the first line):
// @ts-check
You should then get some squiggly red errors in your editor that highlight our mistakes, as pictured below.
You should also see a cross in the bottom left-hand corner with a two by it. Clicking on this will reveal the problems that have been spotted.
And just because you're not on VS Code doesn't mean you can't get the same experience with TypeScript highlighting errors. Most editors these days support the Language Server Protocol (commonly referred to as LSP), which is what VS Code uses to power its TypeScript integration.
It's well worth searching online to find your editor and the recommended plugins to have it set up.
Installing and Running TypeScript Locally
If you're not on VS Code, or you'd like a general solution, you can also run TypeScript on the command line. In this section, I'll show you how.
First, let's generate a new project. This step assumes you have Node and npm installed upon your machine:
mkdir typescript-democd typescript demonpm init -y
Next, add TypeScript to your project:
npm install --save-dev typescript
Note: you could install TypeScript globally on your machine, but I like to install it per-project. That way, I ensure I have control over exactly which version of TypeScript each project uses. This is useful if you have a project you've not touched for a while; you can keep using an older TS version on that project, whilst having a newer project using a newer version.
Once it's installed, you can run the TypeScript compiler (tsc
) to get the same errors (don't worry about these extra flags, as we'll talk more about them shortly):
npx tsc index.js --allowJs --noEmit --target es2015index.js:13:36 - error TS2551: Property 'qerySelector' does not exist on type 'Document'. Did you mean 'querySelector'?13 const imagesContainer = document.qerySelector('#images-container'); ~~~~~~~~~~~~ node_modules/typescript/lib/lib.dom.d.ts:11261:5 11261 querySelector<K extends keyof HTMLElementTagNameMap>(selectors: K): HTMLElementTagNameMap[K] | null; ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 'querySelector' is declared here.index.js:16:9 - error TS2339: Property 'src' does not exist on type 'HTMLElement'.16 img.src = photo.src.medium; ~~~Found 2 errors.
You can see that TypeScript on the command line highlights the same JavaScript code errors that VS Code highlighted in the screenshot above.
Fixing the Errors in Our JavaScript Code
Now that we have TypeScript up and running, let's look at how we can understand and then rectify the errors that TypeScript is flagging.
Let's take a look at our first error.
Property qerySelector
does not exist on type Document
index.js:13:36 - error TS2551: Property 'qerySelector' does not exist on type 'Document'. Did you mean 'querySelector'?13 const imagesContainer = document.qerySelector('#images-container'); node_modules/typescript/lib/lib.dom.d.ts:11261:5 11261 querySelector<K extends keyof HTMLElementTagNameMap>(selectors: K): HTMLElementTagNameMap[K] | null; ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 'querySelector' is declared here.
This can look quite overwhelming if you're not used to reading TypeScript errors, so don't panic if it looks a bit odd! TypeScript has spotted that, on line 13
, we've called a method document.qerySelector
. We meant document.querySelector
but made a mistake when typing. We would have found this out when we tried to run our code in the browser, but TypeScript is able to make us aware of it sooner.
The next part where it highlights lib.dom.d.ts
and the querySelector<K...>
function is diving into more advanced TypeScript code, so don't worry about that yet, but at a high level it's TypeScript showing us that it understands that there's a method called querySelector
, and it suspects we might have wanted that.
Let's now zoom in on the last part of the error message above:
index.js:13:36 - error TS2551: Property 'qerySelector' does not exist on type 'Document'. Did you mean 'querySelector'?
Specifically, I want to look at the text did not exist on type 'Document'
. In TypeScript (and broadly in every typed language), items have what's called a type
.
In TypeScript, numbers like 1
or 2.5
have the type number
, strings like "hello world"
have the type string
, and an instance of an HTML Element has the type HTMLElement
. This is what enables TypeScript's compiler to check that our code is sound. Once it knows the type of something, it knows what functions you can call that take that something, or what methods exist on it.
Note: if you'd like to learn more about data types, please consult “ Introduction to Data Types: Static, Dynamic, Strong & Weak ”.
In our code, TypeScript has seen that we've referred to document
. This is a global variable in the browser, and TypeScript knows that and knows that it has the type of Document
. This type documents (if you pardon the pun!) all of the methods we can call. This is why TypeScript knows that querySelector
is a method, and that the misspelled qerySelector
is not.
We'll see more of these types as we go through the later chapters, but this is where all of TypeScript's power comes from. Soon we'll define our own types, meaning really we can extend the type system to have knowledge about all of our code and what we can and can't do with any particular object in our codebase.
Now let's turn our attention to our next error, which is slightly less clear.
Property src
does not exist on type HTMLElement
index.js:16:9 - error TS2339: Property 'src' does not exist on type 'HTMLElement'.16 img.src = photo.src.medium;
This is one of those errors where sometimes you have to look slightly above the error to find the problem. We know that an HTML image element does have a src
attribute, so why doesn't TypeScript?
const img = document.createElement('image');img.src = photo.src.medium;
The mistake here is on the first line: when you create a new image element, you have to call document.createElement('img')
(because the HTML tag is <img>
, not <image>
). Once we do that, the error goes away, because TypeScript knows that, when you call document.createElement('img')
, you get back an element that has a src
property. And this is all down to the types.
When you call document.createElement('div')
, the object returned is of the type HTMLDivElement
. When you call document.createElement('img')
, the object returned is of type HTMLImageElement
. HTMLImageElement
has a src
property declared on it, so TypeScript knows you can call img.src
. But HTMLDivElement
doesn't, so TypeScript will error.
In the case of document.createElement('image')
, because TypeScript doesn't know about any HTML element with the tag image
, it will return an object of type HTMLElement
(a generic HTML element, not specific to one tag), which also lacks the src
property.
Once we fix those two mistakes and re-run TypeScript, you'll see we get back nothing, which shows that there were no errors. If you've configured your editor to show errors, hopefully there are now none showing.
How to Configure TypeScript
It's a bit of a pain to have to add // @ts-check
to each file, and when we run the command in the terminal having to add those extra flags. TypeScript lets you instead enable it on a JavaScript project by creating a jsconfig.json
file.
Create jsconfig.json
in the root directory of our project and place this inside it:
{ "compilerOptions": { "checkJs": true, "noEmit": true, "target": "es2015" }, "include": ["*.js"]}
This configures the TypeScript compiler (and your editor's TS integration) to:
- Check JavaScript files (the
checkJs
option). - Assume we're building in an ES2015 environment (the
target
option). Defaulting to ES2015 means we can use things like promises without TypeScript giving us errors. - Not output any compiled files (the
noEmit
option). When you're writing TypeScript code in TypeScript source files, you need the compiler to generate JavaScript code for you to run in the browser. As we're writing JavaScript code that's running in the browser, we don't need the compiler to generate any files for us. - Finally,
include: ["*.js"]
instructs TypeScript to look at any JavaScript file in the root directory.
Now that we have this file, you can update your command-line instruction to this:
npx tsc -p jsconfig.json
This will run the compiler with our configuration file (the -p
here is short for "project"), so you no longer need to pass all those flags through when running TypeScript.
Working in strict mode
Now we're here, let's see how we can make TypeScript even more thorough when checking our code. TypeScript supports something called "strict mode", which instructs TypeScript to check our code more thoroughly and ensure that we deal with any potential times where, for example, an object might be undefined
. To make this clearer, let's turn it on and see what errors we get. Add "strict": true
to the "compilerOptions"
part of jsconfig.json
, and then re-run TypeScript on the command line.
When you make a change to the jsconfig.json
file, you may find you need to restart your editor for it to pick up those changes. So if you're not seeing the same errors as me, give that a go.
npx tsc -p jsconfig.jsonindex.js:3:28 - error TS7006: Parameter 'searchTerm' implicitly has an 'any' type.3 async function fetchImages(searchTerm, perPage) { ~~~~~~~~~~index.js:3:40 - error TS7006: Parameter 'perPage' implicitly has an 'any' type.3 async function fetchImages(searchTerm, perPage) { ~~~~~~~index.js:15:5 - error TS2531: Object is possibly 'null'.15 imagesContainer.append(img); ~~~~~~~~~~~~~~~Found 3 errors.
Let's start with the last error first and come back to the others:
index.js:15:5 - error TS2531: Object is possibly 'null'.15 imagesContainer.append(img); ~~~~~~~~~~~~~~~
And let's look at how imagesContainer
is defined:
const imagesContainer = document.querySelector('#images-container');
Turning on strict
mode has made TypeScript stricter at ensuring that values we expect to exist do exist. In this case, it's not guaranteed that document.querySelector('#images-container')
will actually return an element; what if it's not found? document.querySelector
will return null
if an element is not found, and now we've enabled strict mode, TypeScript is telling us that imagesContainer
might actually be null
.
Union Types
Prior to turning on strict mode, the type of imagesContainer
was Element
, but now we've turned on strict mode the type of imagesContainer
is Element | null
. The |
(pipe) operator creates union types — which you can read as "or" — so here imagesContainer
is of type Element
or null
. When TypeScript says to us Object is possibly 'null'
, that's exactly what it's telling us, and it wants us to ensure that the object does exist before we use it. Let's fix this by throwing an error should we not find the images container element:
const imagesContainer = document.querySelector('#images-container');if (imagesContainer === null) { throw new Error('Could not find images-container element.')}for (const photo of data.photos) { const img = document.createElement('img'); img.src = photo.src.medium; imagesContainer.append(img);}
TypeScript is now happy; we've dealt with the null
case by throwing an error. TypeScript is smart enough to understand now that, should our code not throw an error on the third line in the above snippet, imagesContainer
is not null
, and therefore must exist and must be of type Element
. Its type was Element | null
, but if it was null
we would have thrown an error, so now it must be Element
. This functionality is known as type narrowing and is a very useful concept to be aware of.
Implicit any
Now let's turn our attention to the remaining two errors we have:
index.js:3:28 - error TS7006: Parameter 'searchTerm' implicitly has an 'any' type.3 async function fetchImages(searchTerm, perPage) { ~~~~~~~~~~index.js:3:40 - error TS7006: Parameter 'perPage' implicitly has an 'any' type.3 async function fetchImages(searchTerm, perPage) {
One of the implications of turning on strict mode is that it turns on a rule called noImplicitAny
. By default, when TypeScript doesn't know the type of something, it will default to giving it a special TypeScript type called any
. any
is not a great type to have in your code, because there are no rules associated with it in terms of what the compiler will check. It will allow anything to happen. I like to picture it as the compiler throwing its hands up in the air and saying "I can't help you here!" Using any
disables any useful type checking for that particular variable, so I highly recommend avoiding it.
Describe the Function Signature with JSDoc
The two errors above are TypeScript telling us that we've not told it what types the two variables our function takes are, and that it's defaulting them back to any
. The good news is that giving TypeScript this information used to mean rewriting your file into TypeScript code, but TypeScript now supports a hefty subset of JSDoc syntax, which lets you provide type information to TypeScript via JavaScript comments. For example, here's how we can provide type information to our fetchImages
function:
/** * @param {string} searchTerm * @param {number} perPage * * @return void */async function fetchImages(searchTerm, perPage) { // function body here}
All JSDoc comments must start with /**
(note the extra *
at the beginning) and within them we use special tags, starting with @
, to denote type properties. Here we declare two parameters (@param
), and then we put their type in curly braces (just like regular JavaScript objects). Here we make it clear that searchTerm
is a string
and perPage
is a number. While we're at it, we also use @return
to declare what this function returns. In our case it returns nothing, and the type we use in TypeScript to declare that is void
. Let's now re-run the compiler and see what it says:
npx tsc -p jsconfig.jsonindex.js:30:13 - error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.30 fetchImages(5, 'cats') ~index.js:31:1 - error TS2554: Expected 2 arguments, but got 1.31 fetchImages('puppies') ~~~~~~~~~~~~~~~~~~~~~~ index.js:9:40 9 async function fetchImages(searchTerm, perPage) { ~~~~~~~ An argument for 'perPage' was not provided.Found 2 errors.
This is the beauty of TypeScript. Giving the compiler extra information, it can now spot errors in how we're calling the code that it couldn't before. In this case, it's found two calls to fetchImages
where we've got the arguments in the wrong order, and the second where we've forgotten the perPage
argument (neither searchTerm
, perPage
are optional parameters). Let's just delete these calls, but I hope it helps demonstrate the power of the compiler and the benefits of giving the compiler extra type information.
Declaring Data Types Using an Interface
Although not flagged by the compiler, one issue our code still has is in this line:
const data = await result.json();
The problem here is that the return type of await result.json()
is any
. This is because, when you take an API response and convert it into JSON, TypeScript has no idea what data is in there, so it defaults to any
. But because we know what the Pexels API returns, we can give it some type information by using TypeScript interfaces. These let us tell TypeScript about the shape of an object: what properties it has, and what values those properties have. Let's declare an interface — again, using JSDoc syntax, that represents the data returned from the Pexels API. I used the Pexels API reference to figure out what data is returned. In this case, we'll actually define two interfaces: one will declare the shape of a single photo
that the Pexels API returns, and the other will declare the overall shape of the response from the API. To define these interfaces using JSDoc, we use @typedef
, which lets us declare more complex types. We then use @property
to declare single properties on that interface. For example, here's the type I create for an individual Photo
. Types should always start with a capital letter. If you'd like to see a full reference to all supported JSDoc functionality, the TypeScript site has a thorough list complete with examples.
/** * @typedef {Object} Photo * @property {{medium: string, large: string, thumbnail: string}} src */
This type says that any object typed as a Photo
will have one property, src
, which itself is an object with three string properties: medium
, large
and thumbnail
. You'll notice that the Pexels API returns more; you don't have to declare every property an object has if you don't want to, but just the subset you need. Here, our app currently only uses the medium
image, but I've declared a couple of extra sizes we might want in the future. Now that we have that type, we can declare the type PexelsSearchResponse
, which will represent what we get back from the API:
/** * @typedef {Object} PexelsSearchResponse * @property {Array<Photo>} photos */
This is where you can see the value of declaring your own types; we declare that this object has one property, photos
, and then declare that its value is an array, where each item is of type Photo
. That's what the Array<X>
syntax denotes: it's an array where each item in the array is of type X
. [1, 2, 3]
would be an Array<number>
, for example. Once we've done that, we can then use the @type
JSDoc comment to tell TypeScript that the data we get back from result.json()
is of the type PexelsSearchResponse
:
/** @type {PexelsSearchResponse} */const data = await result.json();
@type
isn't something you should reach for all the time. Normally, you want the compiler to intelligently figure out the type of things, rather than have to bluntly tell it. But because result.json()
returns any
, we're good here to override that with our type.
Test if everything is working
To prove that this is working, I've deliberately misspelled medium
when referencing the photo's URL:
for (const photo of data.photos) { const img = document.createElement('img'); img.src = photo.src.mediun; // typo! imagesContainer.append(img);}
If we run TypeScript again, we'll see the issue that TypeScript wouldn't have spotted if we hadn't done the work we just did to declare the interface:
index.js:35:25 - error TS2551: Property 'mediun' does not exist on type '{ medium: string; large: string; thumbnail: string; }'. Did you mean 'medium'?35 img.src = photo.src.mediun; ~~~~~~ index.js:18:18 18 * @property {{medium: string, large: string, thumbnail: string}} src ~~~~~~ 'medium' is declared here.Found 1 error.
Conclusion
TypeScript has a lot to offer developers working on complicated codebases. Its ability to shorten the feedback loop and show you errors before you have to recompile and load up the browser is really valuable. We've seen how it can be used on any existing JavaScript project (avoiding the need to rewrite your code into .ts
files) and how easy it is to get started. I hope you've enjoyed this TypeScript tutorial for beginners. In the next chapters, we'll start putting this knowledge into action and build out a fully blown app using TypeScript.