Skip to content

b

First steps with TypeScript

After the brief introduction to the main principles of TypeScript, we are now ready to start our journey towards becoming FullStack TypeScript developers. Rather than giving you a thorough introduction to all aspects of TypeScript, we will focus in this part on the most common issues that arise when developing express backends or React frontends with TypeScript. In addition to language features, we will also have a strong emphasis on tooling.

Setting things up

Install TypeScript support to your editor of choice. Visual Studio Code works natively with TypeScript.

As mentioned earlier, TypeScript code is not executable by itself. It has to be first compiled into executable JavaScript. When TypeScript is compiled into JavaScript, the code becomes subject for type erasure. This means that type annotations, interfaces, type aliases, and other type system constructs are removed and the result is pure ready-to-run JavaScript.

In a production environment, the need for compilation often means that you have to set up a "build step." During the build step all TypeScript code is compiled into JavaScript in a separate folder, and the production environment then runs the code from that folder. In a development environment, it is often handier to make use of real-time compilation and auto-reloading in order to be able to see the resulting changes more quickly.

Let's start writing our first TypeScript app. To keep things simple, let's start by using the npm package ts-node. It compiles and executes the specified TypeScript file immediately, so that there is no need for a separate compilation step.

You can install both ts-node and the official typescript package globally by running:

npm install -g ts-node typescript

If you can't or don't want to install global packages, you can create an npm project which has the required dependencies and run your scripts in it. We will also take this approach.

As we recall from part 3, an npm project is set by running the command npm init in an empty directory. Then we can install the dependencies by running

npm install --save-dev ts-node typescript

and set up scripts within the package.json:

{
  // ..
  "scripts": {
    "ts-node": "ts-node"  },
  // ..
}

You can now use ts-node within this directory by running npm run ts-node. Note that if you are using ts-node through package.json, all command-line arguments for the script need to be prefixed with --. So if you want to run file.ts with ts-node, the whole command is:

npm run ts-node -- file.ts

It is worth mentioning that TypeScript also provides an online playground, where you can quickly try out TypeScript code and instantly see the resulting JavaScript and possible compilation errors. You can access TypeScript's official playground here.

NB: The playground might contain different tsconfig rules (which will be introduced later) than your local environment, which is why you might see different warnings there compared to your local environment. The playground's tsconfig is modifiable through the config dropdown menu.

A note about the coding style

JavaScript is a quite relaxed language in itself, and things can often be done in multiple different ways. For example, we have named vs anonymous functions, using const and let or var, and the use of semicolons. This part of the course differs from the rest by using semicolons. It is not a TypeScript-specific pattern but a general coding style decision taken when creating any kind of JavaScript project. Whether to use them or not is usually in the hands of the programmer, but since it is expected to adapt one's coding habits to the existing codebase, you are expected to use semicolons and to adjust to the coding style in the exercises for this part. This part has some other coding style differences compared to the rest of the course as well, e.g. in the directory naming conventions.

Let us add a configuration file tsconfig.json to the project with the following content:

{
  "compilerOptions":{
    "noImplicitAny": false
  }
}

The tsconfig.json file is used to define how the TypeScript compiler should interpret the code, how strictly the compiler should work, which files to watch or ignore, and much more. For now we will only use the compiler option noImplicitAny, that does not require to have types for all variables used.

Let's start by creating a simple Multiplier. It looks exactly as it would in JavaScript.

const multiplicator = (a, b, printText) => {
  console.log(printText,  a * b);
}

multiplicator(2, 4, 'Multiplied numbers 2 and 4, the result is:');

As you can see, this is still ordinary basic JavaScript with no additional TS features. It compiles and runs nicely with npm run ts-node -- multiplier.ts, as it would with Node.

But what happens if we end up passing wrong types of arguments to the multiplicator function?

Let's try it out!

const multiplicator = (a, b, printText) => {
  console.log(printText,  a * b);
}

multiplicator('how about a string?', 4, 'Multiplied a string and 4, the result is:');

Now when we run the code, the output is: Multiplied a string and 4, the result is: NaN.

Wouldn't it be nice if the language itself could prevent us from ending up in situations like this? This is where we see the first benefits of TypeScript. Let's add types to the parameters and see where it takes us.

TypeScript natively supports multiple types including number, string and Array. See the comprehensive list here. More complex custom types can also be created.

The first two parameters of our function are the number and the string primitives, respectively:

const multiplicator = (a: number, b: number, printText: string) => {
  console.log(printText,  a * b);
}

multiplicator('how about a string?', 4, 'Multiplied a string and 4, the result is:');

Now the code is no longer valid JavaScript, but in fact TypeScript. When we try to run the code, we notice that it does not compile:

fullstack content

One of the best things in TypeScript's editor support is that you don't necessarily need to even run the code to see the issues. The VSCode plugin is so efficient, that it informs you immediately when you are trying to use an incorrect type:

fullstack content

Creating your first own types

Let's expand our multiplicator into a slightly more versatile calculator that also supports addition and division. The calculator should accept three arguments: two numbers and the operation, either multiply, add or divide, which tells it what to do with the numbers.

In JavaScript, the code would require additional validation to make sure the last argument is indeed a string. TypeScript offers a way to define specific types for inputs, which describe exactly what type of input is acceptable. On top of that, TypeScript can also show the info of the accepted values already at editor level.

We can create a type using the TypeScript native keyword type. Let's describe our type Operation:

type Operation = 'multiply' | 'add' | 'divide';

Now the Operation type accepts only three kinds of input; exactly the three strings we wanted. Using the OR operator | we can define a variable to accept multiple values by creating a union type. In this case, we used exact strings (that, in technical terms, are called string literal types) but with unions, you could also make the compiler accept for example both string and number: string | number.

The type keyword defines a new name for a type: a type alias. Since the defined type is a union of three possible values, it is handy to give it an alias that has a representative name.

Let's look at our calculator now:

type Operation = 'multiply' | 'add' | 'divide';

const calculator = (a: number, b: number, op: Operation) => {
  if (op === 'multiply') {
    return a * b;
  } else if (op === 'add') {
    return a + b;
  } else if (op === 'divide') {
    if (b === 0) return 'can\'t divide by 0!';
    return a / b;
  }
}

Now, when we hover on top of the Operation type in the calculator function, we can immediately see suggestions on what to do with it:

fullstack content

And if we try to use a value that is not within the Operation type, we get the familiar red warning signal and extra info from our editor:

fullstack content

This is already pretty nice, but one thing we haven't touched yet is typing the return value of a function. Usually, you want to know what a function returns, and it would be nice to have a guarantee that it actually returns what it says it does. Let's add a return value number to the calculator function:

type Operation = 'multiply' | 'add' | 'divide';

const calculator = (a: number, b: number, op: Operation): number => {
  if (op === 'multiply') {
    return a * b;
  } else if (op === 'add') {
    return a + b;
  } else if (op === 'divide') {
    if (b === 0) return 'this cannot be done';
    return a / b;
  }
}

The compiler complains straight away because, in one case, the function returns a string. There are couple of ways to fix this. We could extend the return type to allow string values, like so:

const calculator = (a: number, b: number, op: Operation): number | string =>  { 
  // ...
}

Or we could create a return type which includes both possible types, much like our Operation type:

type Result = string | number;

const calculator = (a: number, b: number, op: Operation): Result =>  {
  // ...
}

But now the question is if it's really okay for the function to return a string?

When your code can end up in a situation where something is divided by 0, something has probably gone terribly wrong and an error should be thrown and handled where the function was called. When you are deciding to return values you weren't originally expecting, the warnings you see from TypeScript prevent you from making rushed decisions and help you to keep your code working as expected.

One more thing to consider is, that even though we have defined types for our parameters, the generated JavaScript used at runtime does not contain the type checks. So if, for example, the operation parameter's value comes from an external interface, there is no definite guarantee that it will be one of the allowed values. Therefore, it's still better to include error handling and be prepared for the unexpected to happen. In this case, when there are multiple possible accepted values and all unexpected ones should result in an error, the switch...case statement suits better than if...else in our code.

The code of our calculator should actually look something like this:

type Operation = 'multiply' | 'add' | 'divide';

type Result = number;
const calculator = (a: number, b: number, op: Operation) : Result => {  switch(op) {
    case 'multiply':
      return a * b;
    case 'divide':
      if (b === 0) throw new Error('Can\'t divide by 0!');      return a / b;
    case 'add':
      return a + b;
    default:
      throw new Error('Operation is not multiply, add or divide!');  }
}

try {
  console.log(calculator(1, 5 , 'divide'));
} catch (error: unknown) {
  let errorMessage = 'Something went wrong.'
  if (error instanceof Error) {
    errorMessage += ' Error: ' + error.message;
  }
  console.log(errorMessage);
}

As of TypeScript 4.0, catch blocks allow you to specify the type of catch clause variables. Pre-4.4, all catch clause variables were of type any. However, with the release of 4.4, the default type is unknown. The unknown is a kind of top type that was introduced in TypeScript version 3 to be the type-safe counterpart of any. Anything is assignable to unknown, but unknown isn’t assignable to anything but itself and any without a type assertion or a control flow-based narrowing. Likewise, no operations are permitted on an unknown without first asserting or narrowing to a more specific type.

The programs we have written are alright, but it sure would be better if we could use command-line arguments instead of always having to change the code to calculate different things.

Let's try it out, as we would in a regular Node application, by accessing process.argv. If you are using a recent npm-version (7.0 or later), there are no problems but with an older setup something is not right:

fullstack content

So what is the problem in older setups?

@types/{npm_package}

Let's return to the basic idea of TypeScript. TypeScript expects all globally-used code to be typed, as it does for your own code when your project has a reasonable configuration. The TypeScript library itself contains only typings for the code of the TypeScript package. It is possible to write your own typings for a library, but that is almost never needed - since the TypeScript community has done it for us!

As with npm, the TypeScript world also celebrates open-source code. The community is active and continuously reacting to updates and changes in commonly-used npm packages. You can almost always find the typings for npm packages, so you don't have to create types for all of your thousands of dependencies alone.

Usually, types for existing packages can be found from the @types organization within npm, and you can add the relevant types to your project by installing an npm package with the name of your package with a @types/ prefix. For example: npm install --save-dev @types/react @types/express @types/lodash @types/jest @types/mongoose and so on and so on. The @types/* are maintained by Definitely typed, a community project with the goal of maintaining types of everything in one place.

Sometimes, an npm package can also include its types within the code and, in that case, installing the corresponding @types/* is not necessary.

NB: Since the typings are only used before compilation, the typings are not needed in the production build and they should always be in the devDependencies of the package.json.

Since the global variable process is defined by Node itself, we get its typings by from the package @types/node.

Since version 10.0 ts-node has defined @types/node as a peer dependency. If the version of npm is at least 7.0, the peer dependencies of a project automatically installed by then npm. If you have an older npm, the peer dependency must be installed explicitly:

npm install --save-dev @types/node

When the package @types/node is installed, the compiler does not complain about the variable process. Note that there is no need to require the types to the code, the installation of the package is enough!

Improving the project

Next, let's add npm scripts to run our two programs multiplier and calculator:

{
  "name": "fs-open",
  "version": "1.0.0",
  "description": "",
  "main": "index.ts",
  "scripts": {
    "ts-node": "ts-node",
    "multiply": "ts-node multiplier.ts",    "calculate": "ts-node calculator.ts"  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "ts-node": "^10.5.0",
    "typescript": "^4.5.5"
  }
}

We can get the multiplier to work with command-line parameters with the following changes:

const multiplicator = (a: number, b: number, printText: string) => {
  console.log(printText,  a * b);
}

const a: number = Number(process.argv[2])
const b: number = Number(process.argv[3])
multiplicator(a, b, `Multiplied ${a} and ${b}, the result is:`);

And we can run it with:

npm run multiply 5 2

If the program is run with parameters that are not of the right type, e.g.

npm run multiply 5 lol

it "works" but gives us the answer:

Multiplied 5 and NaN, the result is: NaN

The reason for this is, that Number('lol') returns NaN, which is actually type number, so TypeScript has no power to rescue us from this kind of situation.

In order to prevent this kind of behaviour, we have to validate the data given to us from the command line.

The improved version of the multiplicator looks like this:

interface MultiplyValues {
  value1: number;
  value2: number;
}

const parseArguments = (args: Array<string>): MultiplyValues => {
  if (args.length < 4) throw new Error('Not enough arguments');
  if (args.length > 4) throw new Error('Too many arguments');

  if (!isNaN(Number(args[2])) && !isNaN(Number(args[3]))) {
    return {
      value1: Number(args[2]),
      value2: Number(args[3])
    }
  } else {
    throw new Error('Provided values were not numbers!');
  }
}

const multiplicator = (a: number, b: number, printText: string) => {
  console.log(printText,  a * b);
}

try {
  const { value1, value2 } = parseArguments(process.argv);
  multiplicator(value1, value2, `Multiplied ${value1} and ${value2}, the result is:`);
} catch (error: unknown) {
  let errorMessage = 'Something bad happened.'
  if (error instanceof Error) {
    errorMessage += ' Error: ' + error.message;
  }
  console.log(errorMessage);
}

When we now run the program:

npm run multiply 1 lol

we get a proper error message:

Something bad happened. Error: Provided values were not numbers!

The definition of the function parseArguments has a couple of interesting things:

const parseArguments = (args: Array<string>): MultiplyValues => {
  // ...
}

Firstly, the parameter args is an array of strings. The return value has the type MultiplyValues, which is defined as follows:

interface MultiplyValues {
  value1: number;
  value2: number;
}

The definition utilizes TypeScript's Interface object type keyword, which is one way to define the "shape" an object should have. In our case it is quite obvious that the return value should be an object with the two properties value1 and value2, which should both be of type number.

More about tsconfig

We have so far used only one tsconfig rule noImplicitAny. It's a good place to start, but now it's time to look into the config file a little deeper.

As mentioned, the tsconfig.json file contains all your core configurations on how you want TypeScript to work in your project.

Let's specify the following configurations in our tsconfig.json file:

{
  "compilerOptions": {
    "target": "ES2020",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "esModuleInterop": true,
    "moduleResolution": "node"
  }
}

Do not worry too much about the compilerOptions; they will be under closer inspection later on.

You can find explanations for each of the configurations from the TypeScript documentation, or from the really handy tsconfig page, or from the tsconfig schema definition, which unfortunately is formatted a little worse than the first two options.

Adding Express to the mix

Right now, we are in a pretty good place. Our project is set up and we have two executable calculators in it. However, since our aim is to learn FullStack development, it is time to start working with some HTTP requests.

Let us start by installing Express:

npm install express

and then add the start script to package.json:

{
  // ..
  "scripts": {
    "ts-node": "ts-node",
    "multiply": "ts-node multiplier.ts",
    "calculate": "ts-node calculator.ts",
    "start": "ts-node index.ts"  },
  // ..
}

Now we can create the file index.ts, and write the HTTP GET ping endpoint to it:

const express = require('express');
const app = express();

app.get('/ping', (req, res) => {
  res.send('pong');
});

const PORT = 3003;

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Everything else seems to be working just fine but, as you'd expect, the req and res parameters of app.get need typing. If you look carefully, VSCode is also complaining about something to about the importing of Express. You can see a short yellow line of dots under the require. Let's hover over the problem:

fullstack content

The complaint is that the 'require' call may be converted to an import. Let us follow the advice and write the import as follows:

import express from 'express';

NB: VSCode offers you a possibility to fix the issues automatically by clicking the Quick Fix... button. Keep your eyes open for these helpers/quick fixes; listening to your editor usually makes your code better and easier to read. The automatic fixes for issues can be a major time saver as well.

Now we run into another problem, the compiler complains about the import statement. Once again, the editor is our best friend when trying to find out what the issue is:

fullstack content

We haven't installed types for express. Let's do what the suggestion says and run:

npm install --save-dev @types/express

And no more errors! Let's take a look at what changed.

When we hover over the require statement, we can see the compiler interprets everything express-related to be of type any.

fullstack content

Whereas when we use import, the editor knows the actual types:

fullstack content

Which import statement to use depends on the export method used in the imported package.

A good rule of thumb is to try importing a module using the import statement first. We will always use this method in the frontend. If import does not work, try a combined method: import ... = require('...').

We strongly suggest you read more about TypeScript modules here.

There is one more problem with the code:

fullstack content

This is because we banned unused parameters in our tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "esModuleInterop": true
  }
}

This configuration might create problems if you have library-wide predefined functions which require declaring a variable even if it's not used at all, as is the case here. Fortunately, this issue has already been solved on configuration level. Once again hovering over the issue gives us a solution. This time we can just click the quick fix button:

fullstack content

If it is absolutely impossible to get rid of an unused variable, you can prefix it with an underscore to inform the compiler you have thought about it and there is nothing you can do.

Let's rename the req variable to _req. Finally we are ready to start the application. It seems to work fine:

fullstack content

To simplify the development, we should enable auto-reloading to improve our workflow. In this course, you have already used nodemon, but ts-node has an alternative called ts-node-dev. It is meant to be used only with a development environment which takes care of recompilation on every change, so restarting the application won't be necessary.

Let's install ts-node-dev to our development dependencies:

npm install --save-dev ts-node-dev

Add a script to package.json:

{
  // ...
  "scripts": {
      // ...
      "dev": "ts-node-dev index.ts",  },
  // ...
}

And now, by running npm run dev, we have a working, auto-reloading development environment for our project!

The horrors of any

Now that we have our first endpoints completed, you might notice we have used barely any TypeScript in these small examples. When examining the code a bit closer, we can see a few dangers lurking there.

Let's add the HTTP POST endpoint calculate to our app:

import { calculator } from './calculator';

// ...

app.post('/calculate', (req, res) => {
  const { value1, value2, op } = req.body;

  const result = calculator(value1, value2, op);
  res.send(result);
});

When you hover over the calculate function, you can see the typing of the calculator even though the code itself does not contain any typings:

fullstack content

But if you hover over the values parsed from the request, an issue arises:

fullstack content

All of the variables have type any. It is not all that surprising, as no one has given them a type yet. There are a couple of ways to fix this, but first, we have to consider why this is accepted and where the type any came from.

In TypeScript, every untyped variable whose type cannot be inferred implicitly becomes type any. Any is a kind of "wild card" type which literally stands for whatever type. Things become implicitly any type quite often when one forgets to type functions.

We can also explicitly type things any. The only difference between implicit and explicit any type is how the code looks; the compiler does not care about the difference.

Programmers however see the code differently when any is explicitly enforced than when it is implicitly inferred. Implicit any typings are usually considered problematic, since it is quite often due to the coder forgetting to assign types (or being too lazy to do it), and it also means that the full power of TypeScript is not properly exploited.

This is why the configuration rule noImplicitAny exists on compiler level, and it is highly recommended to keep it on at all times. In the rare occasions when you truly cannot know what the type of a variable is, you should explicitly state that in the code:

const a : any = /* no clue what the type will be! */.

We already have noImplicitAny configured in our example, so why does the compiler not complain about the implicit any types? The reason is that the query field of an express Request object is explicitly typed any. The same is true for the request.body field we use to post data to an app.

What if we would like to prevent developers from using any type at all? Fortunately, we have other methods than tsconfig.json to enforce coding style. What we can do is use eslint to manage our code. Let's install eslint and its TypeScript extensions:

npm install --save-dev eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser

We will configure eslint to disallow explicit any. Write the following rules to .eslintrc:

{
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": 11,
    "sourceType": "module"
  },
  "plugins": ["@typescript-eslint"],
  "rules": {
    "@typescript-eslint/no-explicit-any": 2  }
}

(Newer versions of eslint has this rule on by default, so you don't necessarily need to add it separately.)

Let us also set up a lint npm script to inspect the files with .ts extension by modifying the package.json file:

{
  // ...
  "scripts": {
      "start": "ts-node index.ts",
      "dev": "ts-node-dev index.ts",
      "lint": "eslint --ext .ts ."      //  ...
  },
  // ...
}

Now lint will complain if we try to define a variable of type any:

fullstack content

@typescript-eslint has a lot of TypeScript-specific eslint rules, but you can also use all basic eslint rules in TypeScript projects. For now, we should probably go with the recommended settings, and we will modify the rules as we go along whenever we find something we want to change the behavior of.

On top of the recommended settings, we should try to get familiar with the coding style required in this part and set the semicolon at the end of each line of code to required.

So we will use the following .eslintrc

{
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:@typescript-eslint/recommended-requiring-type-checking"
  ],
  "plugins": ["@typescript-eslint"],
  "env": {
    "node": true,
    "es6": true
  },
  "rules": {
    "@typescript-eslint/semi": ["error"],
    "@typescript-eslint/explicit-function-return-type": "off",
    "@typescript-eslint/explicit-module-boundary-types": "off",
    "@typescript-eslint/restrict-template-expressions": "off",
    "@typescript-eslint/restrict-plus-operands": "off",
    "@typescript-eslint/no-unused-vars": [
      "error",
      { "argsIgnorePattern": "^_" }
    ],
    "no-case-declarations": "off"
  },
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "project": "./tsconfig.json"
  }
}

There are quite a few semicolons missing, but those are easy to add. We also have to solve the ESlint issues concerning the any-type:

fullstack content

We could and probably should disable some ESlint rules to get the data from the request body.

Disabling @typescript-eslint/no-unsafe-assignment for the destructuring assignment is nearly enough:

app.post('/calculate', (req, res) => {
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment   const { value1, value2, op } = req.body;

  const result = calculator(Number(value1), Number(value2), op);
  res.send(result);
});

However this still leaves one problem to deal with, the last parameter in the function call is not safe:

fullstack content

We can just disable another ESlint rule to get rid of that:

app.post('/calculate', (req, res) => {
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
  const { value1, value2, op } = req.body;

  // eslint-disable-next-line @typescript-eslint/no-unsafe-argument  const result = calculator(Number(value1), Number(value2), op);
  res.send(result);
});

We now got ESlint silenced but we are totally on mercy of the user. We most definitively should do some validation to the post data and give a proper error message if the data is invalid:

app.post('/calculate', (req, res) => {
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
  const { value1, value2, op } = req.body;

  if ( !value1 || isNaN(Number(value1))) {    return res.status(400).send({ error: '...'});  }
  // more validations here...

  // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
  const result = calculator(Number(value1), Number(value2), op);
  return res.send(result);
});