Skip to content

Instantly share code, notes, and snippets.

@DarkStoorM
Last active January 13, 2022 10:33
Show Gist options
  • Save DarkStoorM/2468f280f8175297a2291dc658cf1479 to your computer and use it in GitHub Desktop.
Save DarkStoorM/2468f280f8175297a2291dc658cf1479 to your computer and use it in GitHub Desktop.

Disclaimer

This is not a Documentation format. This is a very close transcription of Academind's video: "TypeScript Course for Beginners 2021 - Learn TypeScript from Scratch!", which was a part of my learning process. I highly recommend watching the entire 3 hours course available on Academind's YouTube channel.

This is not a 1:1 transcription (roughly 90:100) - do not rely on these notes as some parts of the video were skipped and it contains some self-interpreted content.

GitHub anchors might not work, sadly ¯_(ツ)_/¯


Typescript


Introduction

Typescript is a JavaScript superset, a higher level of coding JS - building up on JavaScript, adding new features and advantages to it. It makes writing JavaScript easier and more powerful.

Note: TypeScript can't be executed by browsers. TypeScript also can't be executed by Node.js. TS is more of a tool and a programming language, which has to be compiled. In the result of that, we can program in a more powerful version of JavaScript utilizing all the advantages and use its features, which then compiles into JavaScript.

TypeScript most importantly, as the name suggests, adds Types - this is a good opportunity to define value types. TS also gives us an advantage of catching errors early, which in JS are mostly caught as in runtime.


Why do we use TypeScript?

Consider the following JavaScript example:

function add(num1, num2) {
  return num1 + num2;
}

console.log(add('2', '3'));

Notice we are passing two numbers as strings. This will obviously be ignored in JavaScript - it will allow us to do that, nothing is stopping us here. This function is fine, but it allows passing values of any type we want, which might result in an unwanted behavior.

In this example, JS will concatenate those two strings, which is not what we wanted - the result will be 23 instead of 5 (we passed two strings, evaluated by addition operator).

This will not throw us an error, not any runtime error will occur. This is rather a logical mistake. This could also lead to huge problems with a JavaScript application we are developing.

Of course, we have various ways of mitigating strategies. We could add a bunch of if checks, validate and sanitize user inputs and so on. All of this is fine, but we could also catch errors during the development, which is possible with TypeScript. The JS error mitigation strategies are not bad, but the developers can still write invalid code - TypeScript is a tool, which helps us writing a better code.

JavaScript weakness - weakly-typed

Let's consider another example (omitting .html since it just contains the form):

const button = document.querySelector("button");
const input1 = document.getElementById("num1");
const input2 = document.getElementById("num2");

function add(num1, num2) {
  return num1 + num2;
}

button.addEventListener("click", function() {
  console.log(add(input1.value, input2.value));
})

When running a page with this code, we would expect to get a result of 15 when we provide those two numbers: 10 and 5. Instead, we see 105. This shows us the weakness of JavaScript. This is not a technical error, not a thrown error, but a logical error in our application.

So where does this error come from? We reach out to our form upon the click event, we pass two values and we add them. No problem, right? Although, we have to remember, that return type of our input1.value is always a string, which is later passed to our add function, and it does not matter if the input is of type number, the retrieved value is always a string.

Since the both values are strings, they are not being added, but always concatenated, because we are not doing anything with those values. We retrieve them and pass them with no conversion or type checks.

This is the issue with JS, that TypeScript can help us with. We could of course add an if check and if those values are numbers and then return the sum or else convert them to a number by prepending a plus sign: return +num1 + +num2.

The above "fix" works, but it required us to write some extra code, which in the end is not the thing we want. We would want to make sure we can't even pass anything other than a number, because in this example, the add function should only operate on numbers and we don't want to write any extra code. Of course, we could add Validators, but that's also extra code.


Installation

In order to see how TypeScript helps us writing a better code, let's install it first.

Go to Typescriptlang.org, into the download section and install it via command (assuming Node.js is already installed), so we will grab it through the NPM.

Once the TS installation is done, we have it available globally on our machine. This also comes with a compiler, which comes with everything that it needs to understand the TypeScript code and to compile it to JavaScript.


Using TypeScript

We can now invoke the npx tsc <filename.ts> command, which runs the TypeScript Compiler. It is important to specify the main index file, which imports the rest of our files

To see it in action, we simply have to create a new file, but we have to remember, that we have to add a .ts extension to each new file, which stands for TypeScript.

We can now copy the code from the above example about JS weakness and the first thing we will see is a bunch of underlined errors.

If we were copying this code from another file within the workspace/folder, some of these constants might be underlined since it recognizes them in another file.

We should end up having the following view with value being underlined and getting some Type Hinting on our add function:

ts

We can even immediately try to compile this code, which will throw us an error:

error TS2339: Property 'value' does not exist on type 'HTMLElement'.

That does not only show upon the compilation, it will also show us this error in out IDE:

ts1

This is why TypeScript is great, it forces us to be more explicit with our code, to double-check it.

There are couple things to consider. While TypeScript forces us to double-check the code, we have to tell it, that our code is correct after we are done checking it. For the sake of completeness of the above example, we will provide a bunch of comments:

/**
 * Since we are dealing with HTML, and we have already made sure that our elements
 * exist and the value TS was yelling at us about also will exist, we can tell TS
 * that our .value will never be null (because it exists) by appending
 * an exclamation mark at the end of the assigned element like so:
 * 
 * const input1 = document.getElementById("num1")!;
 * 
 * But that's not everything. In this example, we have to tell TS what type
 * of element is this, so we are casting it to a type of HTMLInputElement.
 * This is the TypeScript syntax and we can use it in a .ts file.
 */
const button = document.querySelector("button");
const input1 = document.getElementById("num1")! as HTMLInputElement;
const input2 = document.getElementById("num2")! as HTMLInputElement;

/**
 * The additional advantage is that we can define types
 * At first TS will tell us that it doesn't know what type the argument is (num: any), and
 * it would be nice to add a type to it so we know what we are dealing with.
 * 
 * By using the specific syntax, which TS compiler understands, we can define the type
 * of arguments. We do this by adding a colon after an argument and specifying a type
 */
function add(num1: number, num2: number) {
  return num1 + num2;
}

/**
 * Having that out of the way, our IDE will no longer tell us that .value does not exist
 * BUT again we will get yelled at for the type mismatch. .value is of string type,
 * and we are trying to pass a string to an argument that should be of number type.
 * 
 * This error will also be thrown when we try to compile this.
 * TypeScript understands what type we get from out InputElement, 
 * so we can't pass this to the add() function, because it expects a number, so
 * we can quickly convert it to a number by prepending a plus sign
 */
button.addEventListener("click", function() {
  console.log(add(+input1.value, +input2.value));
})

We can now compile this code with npx tsc filename.ts (whatever our filename was), it will compile successfully, and we wil get a .js file in return. Interestingly enough, we see, that we got almost the same code as before, but in vanilla JS. Our type cast is gone, type hints are gone.

Note, that .ts file might throw errors again, because there is an another file with the same definitions.

All our extra additions are gone, because they are only TypeScript features When we compile the code, those features are only used to evaluate our code to find potential errors and then they are stripped out. When we are done compiling, we have to point our HTML files to import the compiled file, not our TypeScript source file, because the browser can't read it.

It's true, we also had to write extra code, but we were forced to do it in a cleaner way, which results in a better, less error-prone code.


TypeScript Overview

We saw TS advantages in action. It helps us write clean code easier.

What does TypeScript add:

Types

With types, we have to be way more explicit about how things work and we can avoid many unexpected and unnecessary errors by using types. In addition to that, we can also use IDEs, which have built-in TypeScript support, which can pick up on those types and give us better autocompletion and built-in errors, which show before we even compile the code, because they also understand TypeScript.

Next-generation JavaScript features

We can use certain Next-generation JavaScript features, which we can write and use in our TypeScript files and then they will get compiled down to JS code with workarounds that will even work on older browsers.

Non-JavaScript Features - Interface, Generics

TS also adds certain features, which only TS understands, like Interfaces or Generics. These are features, which can be compiled to JS, but they don't have to, because they help us during the development, that give us clearer errors and help us avoid even more errors, so it adds more features.

Meta-Programming - Decorators

It also gives us certain Meta-Programming features, like Decorators - a special kind of declaration that can be attached to a class declaration, method, accessor, property, or parameter.

TS is highly configurable

TS compiler is highly configurable, it can be really fine tuned to our requirements - make it stricter, looser or just to make sure it behaves the way we want it to behave.

Modern tooling

With modern IDEs, like VSCode, we also get support with non-JavaScript projects. VSCode for example gives us better support in plain JS files, because it is able to use some TypeScript features under the hood without us explicitly using TypeScript. Out of the box, we get a free gain when being aware of using TypeScript and when using modern tools.


TypeScript Project Setup

Let's start with the basic skeleton. Prepare a folder, and open it in the VSCode. Create an .HTML file and type html within its contents. You will be presented with a list of suggestions - select html:5, it will insert a basic html skeleton snippet.

ts

Now make sure to refer to the script in the <head>. We will compile our code to app.js file.

<script src="app.js" defer></script>

Create a file app.ts and put a simple console.log("Hello") inside.

Now open the terminal (make sure it points to the location where app.ts resides) and type npx tsc app.ts. A new app.js file will appear - this is our compiled code.

When you open this file on a browser, this code should output "Hello" to the console (ctrl + J for Chrome/ FireFox). This is a bunch of steps we have to do in order to see changes in our application. Of course we can speed this up.

We will add a new tool, which will automatically handle this for us. To install such tool, we have to run npm init command in the project folder. This command is available as long as we have Node.js installed. It will ask a couple questions, but we can keep them at defaults by hitting enter all the time. It will give is a package.json file. Now we can run npm install command to install dependencies, which are exclusive to this project:

npm install --save-dev lite-server

This will install an extra tool lite-server. Once it's finished, go to the package.json file, then to the scripts key add a new script named: "start":

{
  "name": "new-project",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "lite-server"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "lite-server": "^2.6.1"
  }
}

Now when we execute npm start - an extra argument after npm can point to a custom script. This will start a simple development server, which always serves index.html file on localhost:3000 by default. We can now simply visit localhost:3000 and we will see our scripts running there. This will also automatically update the page whenever a file is changed.

We can observe this just be saving any file, like our index.html. We will see a confirmation in the terminal:

ts

Keep this running while developing, to make it automatically update the webpage.


Working with Types

TS provides many types to JavaScript. JS itself also knows some data types, but TS adds many more types. TypeScript also enables us to write our own types.

Some of the Core types JavaScript already knows:

number - there is no special type for integer, float. There is no differentiation between number types.

string - Text type defined by a string of characters between apostrophes 'Hi', double quotes "Hi" or backticks `Hi`. Backticks is a special type, it allows writing string templates let str = `Name: ${name}`;

boolean - true, false

objects / etc.

Assuming we already have the basic skeleton ready~~

We can now start working with the types.

Let's say we have a function, which returns a sum of two values, passed as arguments:

function add(n1, n2) {
  return n1 + n2;
}

const number1 = 5;
const number2 = 2.8;

const result = add(number1, number2);
console.log(result);

While we have the lite-server running, all we need to do is recompile the code and the browser will update automatically This will also output a result to the console: 7.8. Nothing special about it, no TypeScript yet.

Let's change the code. Update the number1 constant to a string:

const number1 = '5';

If we save this a recompile, the output will give us 52.8, which is not correct, because we are no longer adding two numbers, but since there is a string, we are concatenating those two values. Of course such situation is not real, we would not just write such code. This could be fetched from some user input or from another script. It's possible to make such errors, but it can be easier to track down!

We can assign types to our function parameters. We can tell TS our arguments are of number type:

function add(n1: number, n2: number) {
  return n1 + n2;
}

With this we are saying: I only want values of number type in my function, no other type should be allowed. As soon as we add types, our IDE will complain about number1 being a string.

img

That means TypeScript's type system only helps us during the development, e.g before the code gets compiled. It does not change JS to work differently at runtime, because browsers have no built-in TypeScript support. It's extremely useful, because it adds an extra sanity check. By default, errors won't block the compilation and still produce the .js file.

We can of course prevent users from providing invalid input in vanilla JS by adding a type check:

if (typeof n1 !== "number" || typeof n2 !== "number") {
  throw new Error("Incorrect input!");
}

return n1 + n2;

But we can already avoid this in TypeScript just by specifying the allowed type of the input values without extra code of a type check. This allows us to fix errors during the development rather than encountering them at runtime.

The key difference is, that JavaScript uses dynamic types, which are resolved at runtime, and TypeScript uses static types set during the development.

It is important to note, that we get TypeScript support only during the development, because JavaScript won't recognize TypeScript features, they are not built into the JS engine - they can not execute in the browser.


More about the core types JavaScript knows

The number is a type of a value like 5 or 5.8. There is no difference between integers, floats. In JS, all numbers a float by default.

There are also strings and booleans, which were already shown in the code example above, but let's update that example:

function add(n1: number, n2: number) {
  return n1 + n2;
}

// Create a constant and pass it to our add() function
const printResult = true;

const result = add(number1, number2, printResult);

We will immediately get an error, out IDE will underline printResult in the function call, because our function does not support three parameters.

Now we just have to make sure our function supports 3 parameters and that parameter is of specific type, in this case: boolean.

We will no longer get an error, since our function accepts 3 parameters. We can also use our new parameter, which will allow printing the result:

function add(n1: number, n2: number, showResult: boolean) {
  // There is no need to strictly check for true, 
  // in JS we look for a truthy value
  if (showResult) {
    console.log(n1 + n2);
  } else {
    return n1 + n2;
  }
}

We can further customize it and pass an additional string, which will be printed to the console if we decide that our function should print something:

const printResult = true;
const resultPhrase = "Result is: ";

// In this case we no longer need to store the result
// because we want to print it
add(number1, number2, printResult, resultPhrase);

// We should also modify our add() function by adding the fourth parameter
// as a string type
function add(n1: number, n2: number, showResult: boolean, phrase: string) {
  if (showResult) {
    console.log(phrase + n1 + n2);
  } else {
    return n1 + n2
  }
}

But there's another problem here? We have reintroduced string conversion again, the numbers are now converted to strings - the addition is evaluated upon calling the output, so it's not treated in a mathematical way. We would have to store the result first, then use it in the output/return:

const result = n1 + n2;

if (showResult) {
  console.log(phrase + result);
} else {
  return result;
}

Now we are correctly calculating our values, and we should not care that it's converted to a string later.

To understand the type assignment a bit more, it's important to note that we are explicitly assigning a type by typing an argument name, then a colon and the type of that argument: function add(n1: number, n2: number). These are special identifiers - a special syntax, which is added by TS. This is not a part of the compiled JavaScript, because JS does not support that. TypeScript's compiler does understand it, IDE also understands it, but JavaScript does not know what a colon after variable with a type means - it will not be a part of the JavaScript output.


Type Assignment and Type Inference

So aren't we explicitly specifying the variable type when we defined our numbers and texts? TS has a built-in feature, which is called Type Inference. This means, that TS does its best to understand which type we have in a certain variable or constant. In our previous code example TS understands, that number1 will always be of type number in the end, because we initialized it with a number. It's easier for TypeScript, because we defined a constant, it will never change, so it will tell us, that number1: 5 - a number.

If we define it as a variable let number1 = 5, it will still detect it as a number, but it will no longer tell us, that number1: 5, but number1: number. We can also define a type like this:

let number1: number = 5;

But, this is redundant and not considered a good practice, because TS is able to perfectly infer a type from a number, string, boolean, etc. This could be important only if we have variables uninitialized:

let number1: number;

number1 = '2'; // This will show us an error now

TS also got us covered when we initialize a variable with one type, and then change its value to a different type:

let phrase = "Result is";

// Error: Type 'number' is not assignable to type 'string'.
resultPhrase = 0;

This is TypeScript's core task: checking types and yelling at us for using them incorrectly.


Object Types

TypeScript also supports Object type: { age: 30 } for example - in TypeScript there are more specific types of objects.

Let's create a new .ts file, where we will make some objects.

const person = {
  name: "Max",
  age: 30,
}

console.log(person);

If we compile this, our lite-server will update the page and there will be a new output in the console {name: "Max", age: 30}.

What we can do in JavaScript is that we can access a property, that does not exist in the object:

// This property does not exist in out person object
// so JS will look at the Prototype
console.log(person.nickname);

But, TypeScript is not happy about that and immediately shows us an error.

img

What we can notice is that TypeScript inferred the type of person, when we hover over it:

img

There is no nickname. This is not just an object, this is a concrete object with specific structure. It requires a name and age. This can be a little confusing, because this looks just like a regular JavaScript object. There is a small difference: we can see a semi-colon after each type. This is not a JavaScript Object anymore - this is the Object Type inferred by TypeScript and Object Types are written almost like objects, but we don't have Key-Value Pairs, but Key-Type Pairs. Object Types are there to describe the type of an object that we will be using in our code.

We could be more generic. We can explicitly assign a type to the constant of object - one of the built-in types:

const person: object = {
  name: "Max",
  age: 30,
}

console.log(person.nickname);

Now when we hover over the person, TS will tell us that person is of type object - (const person: object), and TS really only cares about the fact that it's an object type.

If we compile this, we get an error, because TS still analyzes our code and sees that we try to access something, which does not exist there, but it's important to understand that it all starts with that most generic object type, but often we want to be more specific than that. We don't just want to work with that generic object type. We really want to get the full TS support.

For example, if we ask intellisense for person., we would get no help there at all. Reason for that is that all we tell the IDE is that we have a value in person, which is of type object. Now we will also get an error if we try to access name. Name exist, but what we tell TS here is that we have an object, where we don't give any other information to TS, so actually TypeScript doesn't support any type of property, because we don't tell it anything about the object, so we should be more specific - and we are more specific by setting a specific object type: the thing, which TS also infers automatically. We do this by adding curly braces after the variable name:

const person: {} = {

}

This is TypeScript's notation of a specialized object type, where we provide some information about the structure of the object. So here we can specify the types of the object's properties - assigning ```Key-Type Pairs`:

const person: {
  /*  Key-Type Pairs go here */
  name: string;
  age: number;
} = {
  /*  Property initialization goes here */
  name: "Max",
  age: 30,
}

console.log(person.name);

So we just tell TypeScript, that person object should support a name property of string type, and age property of number type. Now if we try to access person.name, we are not getting any errors, because name now exists in the person object.

If we left just the age property, we would end up getting an error:

img

img

But this is not needed, we can let TypeScript infer the types if we are initializing an object with some data.

Constants, that only have Key-Type Pairs will throw an error, because constants have to be initialized with some values.

let person: {
  /*  Key-Type Pairs go here */
  name: string;
  age: number;
};

Array Types

Arrays are also important types of data. They can store any data: numbers, string, booleans and mixed. TS also supports Arrays. Any JavaScript Array is supported and the types of that can be flexible or strict.

// We can either let TS infer the type (flexible):
const person = {
  name: "Max",
  age: 30,
  hobbies: ["Sports", "Cooking"]
}

// or we can explicitly tell TS what types are the properties (strict)
// In this case, "Hobbies" is an array, but we want it to be an array of
const person: {
  name: string;
  age: number;
  hobbies: string[];
} = {
  name: "Max",
  age: 30,
  hobbies: ["Sports", "Cooking"]
}

Notice that we are not defining hobbies as array, but as an array of strings instead: string[]. We don't have to explicitly define the type of each property if we are initializing an object, because TypeScript is pretty good at type inference. If we define a new property, e.g. hobbies: ["Sports", "Cooking"], we will see, that TS will already infer the type of that property - string[].

We can also initialize a new property with mixed values, TS will also pick up what the supported types are:

img

For variables, we remember we can also explicitly tell TS what will be the type stored of the stored data in that variable:

// Assuming we are not initializing this variable with any data
let favoriteActivities: string[];

// But let's not forget to avoid redundancy by defining the type explicitly
// when initializing a new variable with a value, because TypeScript __will__
// infer the type after all.
let hobbies: string[] = [
  "Sports",
  "Music",
];

// This can be also initialized as:
let hobbies = [
  "Sports",
  "Music",
];

// Now we will only be able to store new values by pushing to this array, 
// because the following will result in an error:
// Type 'string' is not assignable to type 'string[]'.
hobbies = "Sports";

// We can do in fact this:
hobbies = ["Sports"];

// But, assuming we defined our variable to support values of string type,
// we will get an error while trying to mix values even if we provide
// then in an array:
hobbies = ["Sports", 1];

We can still use any[] type, but we lose the benefits TypeScript gives us, we no longer have a type check.

One of the cool things about TypeScript's Type Inference is that it can also infer the type in a for loop.

const hobbies = [
  "Sports",
  "Music",
];

for (const hobby of hobbies) {
  console.log(hobby.toLowerCase());
}

TypeScript knows, that hobbies is an array of strings, so if we try to access a variable inside of the loop, our IDE will suggest string methods, not array methods. It will not complain about trying to call a string method. If we loop through an array of strings, the values will be just strings, and TypeScript knows that.

img


Tuples

TypeScript adds a couple new types, that JavaScript does not know about. Tuple, which exists in other programming languages is not available in vanilla JavaScript, but usable with TypeScript. Tuple in TS is an array, but not an ordinary array. This is a Fixed-Length Array and also Fixed-Type Array.

Tuple is a special construct TS understands, in JavaScript it will be a normal Array, but during the development, it will prevent us from assigning values with unwanted types or structures.

Tuple definition:

let variable: [string, number];

This explicitly tells TS, that variable should only accept an array, where the first element is a string, the second one is a number and it should only accept two elements. Any other type or more elements will throw errors:

img

img

Now where would this be useful? Let's say in our previous example we also have a role property. More details in the comments of the code snippet:

/**
 * Now "role" is our Tuple, and since it's a Fixed-Length/Type Array,
 * in this example we want it to consist of some numeric identifier
 * and a human readable identifier: number and string.
 * 
 * So now "role" should only be made up of two elements. 
 * We could use an object here, but let's say we want an array here
 * for some reason, where the first is a numeric ID and the second one is
 * a string identifier - like a description
 * 
 * Also notice, that when we hover over the "role" property, our IDE
 * will tell us that it supports an array: (string | number)[]
 * This is called a Union Type (but that will come later)
 */
const person = {
  name: "Max",
  age: 30,
  hobbies: ["Sports", "Cooking"],
  role: [2, "author"],
}

// Right now, the downside is that we could execute the following code:
person.role.push("admin");
person.role[1] = 10;

/**
 * That would not make sense, because we need two elements, but TypeScript
 * doesn't know about this yet. The above code would work, because TS
 * knows it can support strings or numbers inside the "role" property.
 * 
 * It would also make no difference to TS if we pushed a bunch of numbers
 * to that array, it would not give us any errors
 */
person.role.push(1, 2, 3, 4, 5);


/**
 * All we need is that it the data should be represented in the 
 * structure we told it to be: First - number, Second - String
 * 
 * A Tuple will be perfect in such scenario. TypeScript correctly infers
 * the type, but we want to explicitly override it, because the Type
 * Inference does not work the way we want.
 * 
 * For that, we have to set the types explicitly for all properties:
 * const person: { Key-Type Pairs } = { Key-Value Pairs }
 * 
 * or without an initialization:
 * let person: { Key-Type Pairs }
 */
const person: {
  name: string;
  age: number;
  hobbies: string[];
  role: [number, string];
} = {
  name: "Max",
  age: 30,
  hobbies: ["Sports", "Cooking"],
  role: [2, "author"],
}

Now that we have the role property defined as a Tuple, we tell TypeScript, that we want that property to be of a special type of array, where the first element should be of number type, and the second argument should be of string type.

Having this, definition, we can no longer execute the following code:

person.role[1] = "abcde";

TS will start yelling at us, that "10" is not assignable to "string".

But why .push() works then? This is an exception, because TypeScript can't catch this when we try to push into a Tuple. Reference post explaining this.


Enums

Loosely related to the idea of a Tuple is an idea of having a couple of specific identifiers, global constants we might be working in our app, which we want to represent as numbers, but to which we want to assign a human readable label. For that we have Enums - they exist in other programming languages, but JavaScript doesn't know them, though.

ENUMs can save us some extra work, where we would have to create a list of user roles, like:

const ADMIN = 0;
const READ_ONLY = 1;
const AUTHOR = 2;

That definition is of course fine, but ENUM makes it a bit easier:

enum Role {
  ADMIN, READ_ONLY, AUTHOR
};

We know this from other programming languages. The first element gets a number 0, second gets 1, and so on.

Let's modify our previous person example:

enum Role {
  ADMIN, READ_ONLY, AUTHOR
};

const person = {
  name: "Max",
  age: 30,
  hobbies: ["Sports", "Cooking"],
  role: Role.ADMIN
}

if (person.role === Role.ADMIN) {
  console.log("Is Admin");
}

Thanks to this ENUM definition, we can use our Role enum in other places as well by accessing Role.ADMIN for example. Our IDE will also suggest the list of value:

img

With this, we don't have to remember if we declared one of the roles as READ_ONLY_USER or READ ONLY. It makes it easier for us to just refer to the enum values. This assigns labels to numbers, so for example, we don't have to remember whether an admin has a role of 0 or 1, etc.

We can also check how the compiled code of that Enum looks like:

var Role;
(function (Role) {
    Role[Role["ADMIN"] = 0] = "ADMIN";
    Role[Role["READ_ONLY"] = 1] = "READ_ONLY";
    Role[Role["AUTHOR"] = 2] = "AUTHOR";
})(Role || (Role = {}));

This is a function, which executes itself and Role is simply managed as an Object, which has our labels as properties with corresponding values that came from the order we declared our labels in.

Of course we are not restricted to the default behavior of the Enum. We can also make the numbers start not from 0, but 5 for example:

const Role { ADMIN = 5, READ_ONLY, AUTHOR };

By specifying a number for the first label, others will pick up on that and simply increment our starting value, so the next will be 6, 7, and so on.

We can also specify values for other labels as well:

const Role { ADMIN = 1, READ_ONLY = 100, AUTHOR = 200 };

// Also note, that leaving the next value uninitialized will increment it from the previous value
// AUTHOR will now be 101
const Role { ADMIN = 1, READ_ONLY = 100, AUTHOR };

Speaking of default behavior, we are also not restricted to numbers.

Important note: When the last Enum label is a string, each next label also have to be initialized with a value.

Side note: We have to notice, that when label values are mixed, TypeScript produces a bit bigger array:

enum Role {
  ADMIN, MODERATOR, USER, GUEST = "none"
};

console.log(Role);

// This outputs: 
0: "ADMIN"
1: "MODERATOR"
2: "USER"
​
ADMIN: 0
GUEST: "none"
MODERATOR: 1
USER: 2

Of course, the values don't really matter, because we are accessing them by referring to enum labels anyway.


The Any Type

The Any type is the most flexible type - this type does not tell TypeScript anything. it basically means we can store any kind of value in there, because there is no specific type assignment there, TS will not yell at us when we use any.

let favoriteActivities: any;

favoriteActivities = 5;
favoriteActivities = ["Abcd"];
favoriteActivities = [];

This tells TypeScript that we can store any data we want unless we specifically tell it to store any kind of data in an array.

let favoriteActivities: any[];

favoriteActivities = 5; // This produces an error
favoriteActivities = ["Abcd"];
favoriteActivities = [];

img

While any type is really flexible, it is a big disadvantage and we want to avoid is as much as possible. any takes away all advantages TS gives us, and only gives us the same experience we get from vanilla JavaScript, where we also have any type on everything. It makes sure that TypeScript compiler can't check anything, because if any variable or property can store any value, then there is not much to check. We can use it as a fallback if we have some value, some kind of data, where we really can't know which kind of data will be store in there then maybe we are using some Runtime checks.

You should really avoid any. If we have a chance of knowing which kind of data we are working with, be explicit about it. Let TypeScript's Inference do its job or explicitly set your own types. Don't fallback to any if you don't need to.


Union Types

Union Type is an another interesting type in TypeScript. First, let's get back to the example from the beginning:

function add(n1: number, n2: number)  {
  const result = n1 + n2;

  return result;
}

But, let's rename it to combine(), because we have a scenario, where we get a number as a result or a concatenated string. We could have an application, where you want a flexible combination function that does work with strings and numbers.

function combine(input1: number, input2: number)  {
  const result = input1 + input2;

  return result;
}

const combinedAges = combine(30, 26);

// Outputs a sum
console.log(combinedAges);

So now the problem is, that if we use this function in a different way, let's say we want to combine names, we will get an error, because the combine() function only accepts numbers as parameters;

// Throws an error: string to number assignment
const combinedNames = combine("Max", "Adam");

We could change the combine function to accept strings now, but the first function call where we combine ages will fail. This is where Union Types could help us. If you have some place in your application - a parameter of a function or a constant/variable, where you accept different kinds of values, then a Union Types will help you to tell TypeScript that we are fine wither with a number or a string. We simply define types separated by a pipe |:

function combine(input1: number | string, input2: number | string)  {
  const result = input1 + input2;

  return result;
}

This is fine, but now we get an error, that '+' operator cannot be applied to types 'string | number' abd 'string | number'. This is not entirely correct, this should work, because we should be able to use the + operator with strings or numbers, but TypeScript only sees that we have a Union Type and it does not analyze what's in the Union Type.

We could add a workaround runtime check by checking the type of the arguments:

function combine(input1: number | string, input2: number | string)  {
  let result: string | number;

  if (typeof input1 === "number" && typeof input2 === "number") {
    // By this, TypeScript knows that we are input1 and input2 will be numbers
    result = input1 + input2;
  } else {
    // Since we are now working on strings, we can explicitly convert both inputs to strings
    // By converting we make sure that we always work with strings
    result = input1.toString() + input2.toString();
  }

  return result;
}

And now what happens when we call this function with different data:

const combinedAges = combine(30, 26);
const combinedNames = combine("Max", "Adam");

console.log(combinedAges, combinedNames);

This will output correctly a sum of numbers, then combined two strings: 56 MaxAdam.

This is how we can use Union Types to be more flexible regarding what we do in a function for example or anywhere else in our code. Our extra Runtime check will not always be required when we work with Union Types, but often will be, because with Union Types we can be more flexible in for example the parameters we accept, but we might have different logic in our function based on which exact type we are getting, so the function is able to work with multiple types of values, but it then does slightly different things depending on the type we are getting. So in this situation we might need such check, but not always.

You will certainly encounter situations in TypeScript programs, where you can use a union Type without a runtime type check. It really depends on the logic you are writing.


Literal Types

Literal Types are type, where you don't just say that a certain variable or parameter should hold for example a number or string, but where you are very clear about the exact value it should hold. Look at the following example:

const number1 = 5.4;

You would expect the number1 to have a number type, but since it's a constant, TypeScript already inferred a literal type. The type of number1 is not actually number, but more specifically, a number that is 5.4. We can confirm that just by hovering over that constant in the IDE.

img

This does not only exist for numbers, but also for strings. With strings it can be very useful. Let's say that in our previous combine() function we expect numbers or strings and we combine them differently based on what we get, but we also want to allow the caller of the function to define how the result should be returned. We can basically force the conversion from number to string or the other way around.

We could do this with a third parameter resultConversion: string. So for example we later then pass a third parameter do that function: as-number or as string. To take a closer look at the literal type, we will use them instead of string.

// Notice new parameter, which is defined with a literal type.
// Now our third parameter can be a string, but only if it's one of those exact strings
function combine(
  input1: number | string,
  input2: number | string,
  resultConversion: "as-number" | "as-string"
) {
  let result: string | number;

  if (typeof input1 === "number" && typeof input2 === "number") {
    // By this, TypeScript knows that we are input1 and input2 will be numbers
    result = input1 + input2;
  } else {
    // Since we are now working on strings, we can explicitly convert both inputs to strings
    // By converting we make sure that we always work with strings
    result = input1.toString() + input2.toString();
  }

  if (resultConversion === "as-number") {
    // explicitly convert to a number (or use parseFloat)
    return +result;
  } else {
    return result.toString();
  }
}

// We now have to pass the third parameter
const combinedAges = combine(30, 26, "as-number");
const combinedStringAges = combine("30", "26", "as-number");

// In case we forgot what literal type we can use, TypeScript will immediately tell us about it
// and throw an error if we try to pass something different than what we have defined
const combinedNumbers = combine(1, 34, "as-text"); // <- errors out

console.log(combinedAges, combinedStringAges, combinedNumbers);

In the above example we forced TypeScript to check if we have passed a string and if it's one of the following literal types: as-number, as-string. We are also using a Union Type. Of course, we could use an Enum here, but since we only have two values, It would be unnecessary to create it just for that - of course assuming we won't add any more types in the future. Otherwise, an Enum would be great.

Literal Types are based on the core types, so in this case as-number is a string, but if this type is specified, the value a variable or a parameter can hold should only be "as-number". This will mostly be used in a context of Union Types, because we would often want it to be one of the specified types.


Type Aliases

When working with Union Types, it can be cumbersome to always repeat the same set of types. You might want to create a new type, which in the end stores this Union Type. For this, you can utilize another cool TypeScript feature - Type Alias.

We create Type Aliases with the type keyword. This is not built into JavaScript. After the type keyword you add a name of a custom type - the name is really up to you, you can create any name you want as long as it nor collides with the reserved keywords, like Date for example. Then after the equals sign you specify a Union Type (even a single type is allowed!). The type alias syntax is as following:

type Combinable = number | string;

This makes sure we always refer to the same types or to the same type setup, so we don't have to repeat ourselves with writing the same Union Type every time:

// Having our Combinable alias ready, we can use it whenever we need to
let number1: Combinable;

We can use this for any type setup:

type Combinable = number | string;
type ConversionDescriptor = "as-number" | "as-string"

Type Aliases are really useful, you can encode more complex type definitions into your own types and reuse them everywhere you need those type setups. It also helps with being a bit more clear about our intentions, for example by choosing a descriptive alias.


Function Return Types and Void

We will dive a bit deeper into functions. We have already worked with functions, and we know we can add parameters and assign types to them. We can do a bit more with functions and types - let's recreate our simple add function. a bit later. The function overall however has one important type: return type. The return type can also be inferred by TypeScript.

function add(n1: number, n2: number) {
  return n1 + n2;
}

When we hover over the add function, we see that TypeScript has already inferred what the return type is.

(switched the theme, sorry)

img

The return type is specified after the function parameter declaration (after the parenthesis), after the colon : symbol.

function funcName (param1: paramType) : returnType {}

// As in the previous example, but with the return type now
function add(n1: number, n2: number) : number {
  return n1 + n2;
}

If we don't specify the return type and change the returned expression, for example: n1.toString() + n2.toString(), TypeScript would figure out that return type is a string. We can explicitly set the return type, and if we set it to string, while still having return n1 + n2, TypeScript will throw us an error, because the result of our calculation does not match the described return type - number is not assignable to 'string'.

In the above example, where we have no reason to explicitly tell TypeScript what the return type is, it would be a good idea to let TypeScript infer it.

Regarding the return types, there is one interesting type: void. We know it from other programming languages, but let's take a closer look anyway. Let's say we have a function, which prints the result of our add() function:

function printResult(num: number) {
  console.log("Result: " + num);
}

printResult(add(5, 12));

If we compile this, we will see the following string: Result: 17. That's not the value returned from the printResult function, though - we would guess it's a string, but when we take a look at the function again, we can see that TypeScript inferred a return type, which is void. Why is that? That function does not return anything, we are only outputting the result of another function call in the console, so there is nothing to return.

img

void is a special return type, JavaScript doesn't really know that and doesn't really have a name for this situation - TypeScript does. TypeScript's inference did its job and inferred the correct result type. Interestingly enough, it will also infer void if we place return; with no value.

img

Of course we can leave that function as it is and let type inference to its job, but sometimes we really want to be clear about what should be the return type of some function.

void really just means that a function does not have a return statement or does not return any value. It does its job, execute its code. That's where void type comes in.

Undefined return type

Interesting thing happens, when we try to output the result of our printResult function. In the first place we get the printed result from earlier, but the console.log of our printResult prints undefined!

console.log(printResult(add(5, 12)));

In JavaScript, if we use a the return value of a function, that does not return anything, we get undefined as a value. As we know, undefined in JS is a real value. Here we get void even though technically we return undefined. Now to make it even more confusing, undefined actually is a type in TypeScript. You can have undefined as a type and for example a brand new variable can receive undefined as value as a type and we won't get any errors. This variable will just forever be undefined - however useful that might be, that's a different question... undefined is a valid type in TypeScript, though.

Nonetheless, we will get an error if we change the return type of our printResult function to undefined

img

We are getting an error, because a function is not allowed to return undefined. Technically, it of course does return undefined, we are also allowed to add return undefined or just return; and we won't get any errors, but TypeScript thinks about functions a bit differently. You should use void if a function returns nothing, and not undefined, because with this you are making things clear that a function does not have a return statement.


Function Types

So we can use types for function parameters and for return values. To take it to the next level, what if there also was a function type itself? Let's say we have the following variable:

// By default, this variable will be of type "any"
// and we know "any" is not that useful
let combineValues;

// We would want eventually to store a pointer to a function in that variable
combineValues = add;

// so in the end we would be able to execute it
// which would give us the correct result
console.log(combineValues(8, 8));

We would expect it to work, because that is normal JS code, we can store a pointer at a function in another variable, and then execute this variable as a function, because it points at that function. The problem here is from a TypeScript perspective is, that combineValues is of any type, so if we set combineValues to another value, like so:

combineValues = add;
combineValues = 5;

console.log(combineValues(5, 5));

this will compile unfortunately, because TS has no chance of detecting that it's unwanted or it could cause problems, but at Runtime we actually get an Error, because we are trying to execute combineValues as a function, but it's a number!

Now we want to avoid it and for that we need to be clear, that combineValue will hold a function. The first step would be set the type of combineValues to Function. This is a type provided by TypeScript and this makes it clear, that whatever we store in here has to be a function.

let combineValues: Function;
combineValues = add;
combineValues = 5;

img

So that's good, but not perfect, because now we can now set it to point at another function, printResult() for example. TypeScript would not complain, because we have provided a Function. But it's not a function, that takes two arguments, so if we compile it, it will not throw us any errors, but we will get incorrect result, because we stored the wrong function.

It would be nice if TypeScript would tell us about that. TS can't inform us that we have to assign a function, but it should be a bit more precise.

This is where Function Types come into play. A Function Type is created with an arrow function notation we know from JavaScript - or at least close to that, except there are no curly braces.

In TypeScript, a Function Type declaration looks like this:

let combineValues: (a: number, b: number) => number;

By this we are telling TypeScript, that in combineValues we are only allowed to store a function, that takes two parameters of a number type - note, that parameter names, don't matter here, but can be descriptive - and returns a number.

With this, TypeScript won't complain when we try to store a pointer to add function, but it will now complain about us trying to store a pointer to printResult, which takes only one parameter.

img

Now we won't be able to the pointer to printResult anymore and the code runs as expected.

Function Types allow us to describe which type of function specifically we want to use somewhere - be that an expected value in a parameter, if we create a function with some callback or like here, in a variable.


Function Types and Callbacks

Now speaking of Callbacks and Function Types, they work pretty much in the same way. Let's say we have a new function addAndHandle(), where we expect to get two numbers and then also a callback function (a function, which is passed as an argument, that should do something with the result). Let's also make clear, that our callback is a function type definition: (num: number) => void, it maybe won't return anything, so we define the void return type, but it will take a number as an argument, because we are passing a number here, so the callback function should accept a number:

function addAndHandle(n1: number, n2: number, cb) {
  // We are not returning the result
  const result = n1 + n2;

  // Instead we want to call the callback function and pass in result
  cb(result);
}

/**
 * We will pass an anonymous function to our function above
 * ! This is not a function type, but a concrete value we are passing in
 * for this third argument, and there we know we will get a number, so we
 * can do whatever we want with it - here called "result"
 */
addAndHandle(10, 20, (result) => {
  console.log(result);
});

If we compile this, we see 30 in out console log. This is the result or our callback function, which we passed to our addAndHandle, that combines two numbers and then calls the passed callback, which has to meet the condition: Get a number, eventually do something with it and return nothing.

The advantage of us defining the callback function definition here is that inside of the function we pass in as a callback, TypeScript is able to infer, that result will be a number, and hance we could do anything with the result here what we could do with a number without explicitly stating the type here, because TypeScript knows that result will be a number. We made it really clear, that our callback will get one argument, which is a number.

If we would expect a second argument in our callback - passing it to our addAndHandle function, we would get an error here:

addAndHandle(10, 20, (result, b) => {
  console.log(result);
})

That's because the callback we expect in our addAndHandle function only should have one argument, so if we then pass a callback, which takes a second argument, that clearly is a mistake. The only thing TS does not pick up is if we return something in the passed callback. If we return something in a callback, we will get a result even though we declared that the callback should not return anything. This however is not a mistake or a bug in TypeScript, it happens on purpose. By specifying void on a callback, we are saying: we will ignore any result you might be returning here.

With that, TypeScript will not throw any errors at us, we are allowed to return something, but it will be ignored.

So this is useful, because we really make it clear, that we are not interested in a returned value from the callback - we are not returning anything, therefore in our addAndHandle function we are only calling the passed callback function, which will only do something to the value we passed to it.

function addAndHandle(n1: number, n2: number, cb: (num: number) => void) {
  const result = n1 + n2;

  // This will not give us anything from cb()
  // and "value" will be undefined
  const value = cb(result);
}

This does not force us to pass a callback, which does not return anything, it just tells us that anything we might return will not be used. For parameters it's different, because it's forced - it really matters if we pass a callback that expects more parameters and it will throw an error in this case, because we pass two arguments, but expect only one.


The "unknown" Type

There are two more types, which are good to be aware of, because they will matter from time to time. The first type is the unknown type. Let's say we have a new variable userInput, which is of type unknown. It's not of type any, which would be the default, but this is a different type introduced by TypeScript. It might be unknown, because we don't know yet what user will enter - if it's a string or a number. Now the interesting thing about unknown, that we can store any value in it without getting errors.

let userInput: unknown;

userInput = 5;
userInput = 'Max';

This is allowed, we won't get any compilation errors. Thus far its the same as if we wouldn't assign a type or explicitly set any. But still, unknown is different to any. Although we will run into issues, where we have an another variable, let's say a string and we try to combine both variables, we will get an error, because we are trying to assign //unknown to string.

let userInput: unknown;
let userName: string;

userInput = "Max";
userName = userInput;

img

So userName expects to get a string, but userInput is not guaranteed to be a string. Even though we assigned a string Max to the userInput it could hold any value, because it's unknown. The fact that we assigned a string to it only matters for that line.

Now the interesting thing is that if we switch userInput from unknown to any, our error goes away, because any is the most flexible type in TypeScript - it basically disables all type checking and TS just says: "do whatever you want".

unknown is a bit more restrictive than any. With unknown we have to first of all check the type that is currently stored before we can assign it to a variable, that for example wants a string. Since the string is wanted here, we could check if the type of userInput === "string", TypeScript will detect this check and understand, that what we want to store in userName is guaranteed to be a string.

let userInput: unknown;
let userName: string;

// unknown
userInput = "Max";

if (typeof userInput === "string") {
  // userInput has now an inferred type of "string"
  userName = userInput;
}

To be able to assign unknown to a variable with a fixed type, we have to write an extra type check. Therefore unknown is a better choice over any if we can't tell exactly what type we will store in there, it might be a number or a string but we know what we want to to with it eventually. It basically makes sure, that we are not allowed to do everything to that value, but at least have some type checking. Of course if we have a chance of knowing in advance, that userInput is always a string or a number, we should use such type or at least a Union type string | number instead of unknown. It's not a type we should use all the time, but it's still better than any.


The "never" type

The last interesting type is the never type. We saw a function, that returns void, so basically doesn't return anything. never is another type functions can return. That sounds a bit strange, so let's have look at how it works. Let's say we have a function generateError(). Here we expect a message parameter, which is a string and maybe some error code. This should generally be a utility function, which generate error objects and throws them.

function generateError(message: string, code: number) {
  // We can throw any object or any value as an error in JavaScript
  throw {
    message: message,
    errorCode: code
  }
}

generateError("An error occurred!", 500);

If we compile this, we will see our error object in the console. This might sound pretty abstract, but actually it isn't - having utility functions like this would be pretty standard in bigger applications, where we don't manually want to throw an error in many places in our app, but where we would want to reach out to one convenient function, which builds an error object for us and maybe throws it immediately. This allows us to call this function with different inputs, but we always have an error being thrown.

The interesting thing about this function is that it does not just return void. We can specify the return type of void, because it of course returns nothing, but actually it does not just return nothing - this function returns never. This function never produces a return value.

If we were to try to store the result of generateError function and log it, there would be no output, because since there's a throw in it, we could say it crashes our script, cancels it. It will always be the case for this function. We could of course wrap it in try-catch, so we can still continue the script, but this function essentially never produces a value. It always crashes this script or a part of the script, if we are using try-catch, and therefore never returns anything. Hence the return type of that function, it's not just void, but also never.

Also another interesting thing is, that the inferred type by TypeScript is void, because never is a newer type, it wasn't built into the newer versions of typescript and therefore void is assumed. It's not bad to leave it as void, but we can be very clear and explicitly set the return type to never to show that it will never return anything. From the code quality perspective it might be clearer regarding our intentions and to make it clear for other developers reading this code that this function is intended to never return anything and to essentially crash/break the script/part of the script.

function generateError(message: string, code: number) : never {
  [..]
}

We can also specify never type to functions, which for example have an infinite loop in them or which can print something to the console.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment