Book Image

Mastering TypeScript - Fourth Edition

By : Nathan Rozentals
4.7 (3)
Book Image

Mastering TypeScript - Fourth Edition

4.7 (3)
By: Nathan Rozentals

Overview of this book

TypeScript is both a language and a set of tools to generate JavaScript, designed by Anders Hejlsberg at Microsoft to help developers write enterprise-scale JavaScript. Mastering Typescript is a golden standard for budding and experienced developers. With a structured approach that will get you up and running with Typescript quickly, this book will introduce core concepts, then build on them to help you understand (and apply) the more advanced language features. You’ll learn by doing while acquiring the best programming practices along the way. This fourth edition also covers a variety of modern JavaScript and TypeScript frameworks, comparing their strengths and weaknesses. You'll explore Angular, React, Vue, RxJs, Express, NodeJS, and others. You'll get up to speed with unit and integration testing, data transformation, serverless technologies, and asynchronous programming. Next, you’ll learn how to integrate with existing JavaScript libraries, control your compiler options, and use decorators and generics. By the end of the book, you will have built a comprehensive set of web applications, having integrated them into a single cohesive website using micro front-end techniques. This book is about learning the language, understanding when to apply its features, and selecting the framework that fits your real-world project perfectly.
Table of Contents (19 chapters)
17
Other Books You May Enjoy
18
Index

any, let, unions, and enums

We have already seen how TypeScript introduced a simple type annotation syntax in order to promote strong typing, which helps us ensure that we are using variables as they are intended to be used within our code. We also know that TypeScript generates JavaScript, and as such must be able to mimic what JavaScript can do. Unfortunately, matching what JavaScript can do may also include relaxing the strict typing rules, and allowing a string value to be assigned to a numeric value, for example. In this section of the chapter, we will introduce the any type, which completely removes the strict type rules associated to a variable, and allows the fluid and unconstrained use of variables, like in JavaScript. We will also strongly recommend not using the any type, as much as possible.

We will also explore the let and const keywords, which are language elements introduced in later versions of the ECMAScript standard, and take a look at how they can be used in TypeScript. We will then take a look at union types, type guards, and type aliases, which allow us to clearly define how we would like our code to manage groups of types. Finally, we will discuss enums, which are a mechanism to replace magic strings, or magic numbers, with human-readable values.

The any type

We have already seen how TypeScript uses the type annotation syntax to define what type a particular variable or function parameter should be. Once we have set a type for a variable, the compiler will ensure that this type is maintained throughout our code base. Unfortunately, this means that we cannot re-create JavaScript where the JavaScript does not match these strict type rules. Consider the following JavaScript code:

var item1 = { id: 1, name: "item 1" };
item1 = { id: 2 };

Here, we create a variable named item1 and assign to it an object value that has an id and name property. We then reassign this variable to an object that only has an id property. This is valid JavaScript code, and therefore, if we are using TypeScript to generate JavaScript, we will need to be able to mimic this functionality.

TypeScript introduces the any type for such occasions. Specifying that an object has a type of any will, in essence, remove the TypeScript strict type checking. The following TypeScript code shows how to use the any type to mimic our original JavaScript code, as follows:

var item1: any = { id: 1, name: "item1" }
item1 = { id: 2 };

Here, we have specified that the type of the item1 variable is any. The any type then allows a variable to follow JavaScript's loosely defined typing rules so that anything can be assigned to anything. Without the type specifier of any, the second line of this code snippet would normally generate an error.

While the any type is a necessary feature of the TypeScript language, and is used for backward compatibility with JavaScript, its usage should be limited as much as possible. As we have seen with untyped JavaScript, excessive use of the any type will quickly lead to coding errors that will be difficult to find. Rather than using the any type, try to figure out the correct type of the object that you are using, and then use this type instead.

We will discuss the concept of interfaces in the next chapter, which are a way of defining custom types. Using interfaces allows us to cover almost every possible combination of types, meaning that using the any type, in most cases, is unnecessary.

We use an acronym within our programming teams, which is: Simply Find an Interface for the Any Type (S.F.I.A.T), pronounced sveat, or sweat. While this may sound rather odd, it simply brings home the point that the any type can and should be defined as an interface, so simply find it.

In short, avoid the any type at any cost.

Explicit casting

As with any strongly typed language, there comes a time when we need to explicitly specify the type of an object. This is generally used when working with object-oriented concepts, such as classes, abstract classes, and interfaces, but this technique can be used on primitive TypeScript types as well.

Let's rewrite our previous example using explicit casting, as follows:

var item1 = <any>{ id: 1, name: "item1" }
item1 = { id: 2 };

Here, we have replaced the : any type specifier on the left-hand side of the assignment operator with an explicit cast of <any> on the right-hand side of the assignment operator. This explicit casting technique uses the angled bracket syntax, that is, < and >, surrounding the name of the type. In essence, this syntax is equivalent to our earlier example, and specifies that the type of the item1 variable is any.

Another variant of this casting technique is to use the as keyword, as follows:

var item1 = { id: 1, name: "item1" } as any;
item1 = { id: 2 };

Here, we have defined the variable named item1, similar to our earlier definitions, but have appended the keyword as and then named the type that this variable should be treated as, which in this case is any. This example, and the previous example, are equivalent in outcome, as the item1 variable is of type any no matter which version of the explicit casting syntax we use.

Hopefully, this will be one of the last times that we use the type of any. Remember that using the type of any removes all strict type checking, and is therefore the antithesis of TypeScript. There may be very specific edge cases where we will still need to use the any type, but these should be few and far between.

The let keyword

The fluid nature of JavaScript variables can sometimes cause errors when we inadvertently define variables with the same name, but in a different scope within a code block. Consider the following TypeScript code:

var index: number = 0;
if (index == 0) {
    var index: number = 2;
    console.log(`index = ${index}`);
}
console.log(`index = ${index}`);

Here, we define a variable named index of type number using the var keyword, and assign it a value of 0. We then test if this value is equal to 0, and if it is, we enter a code block. The first statement in this code block defines a variable named index, of type number, and assigns the value 2 to it. We then print the value of the index variable both inside this code block and outside of it. The output of this code is as follows:

index = 2
index = 2

What this is showing us is that even though we thought we created a new variable within the if code block named index, this variable re-declaration actually points to the original index variable and does not create a new one. So, setting the value of the index variable within the code block will modify the value of the index variable outside of the code block as well. This is not what was intended.

The ES6 JavaScript specification introduces the let keyword to prevent this from happening. Let's refactor the preceding code using the let keyword, as follows:

let index: number = 0;
if (index == 0) {
    let index: number = 2;
    console.log(`index = ${index}`);
}
console.log(`index = ${index}`);

Here, we are defining the index variable by using the let keyword instead of the var keyword, both in the original declaration and within the if code block. No other change to the code is necessary. The output of this code is as follows:

index = 2
index = 0

Here, we can see that modifying the variable named index inside of our if code block does not affect the variable named index that is defined outside of the code block. They are seen as two separate variables.

It is best practice to use the let keyword to define variables, and not to use the var keyword at all. By using let, we are being more explicit about the intended use of these variables, which will help the compiler to pick up any mistakes in our code where these rules are broken.

Const values

When working with variables, it sometimes helps to indicate that the variable's value cannot be modified after is has been created with a specific value. TypeScript uses the const keyword, which was introduced in ES6, in order to accomplish this. Consider the following code:

const constValue = "this should not be changed";
constValue = "updated";

Here, we have defined a variable named constValue and indicated that its value cannot be changed using the const keyword. Attempting to compile this code will result in the following error:

error TS2588: Cannot assign to 'constValue' because it is a constant.

This error is being generated because of the second line in this code snippet. We are attempting to modify the value of the constValue variable, which is not allowed.

It is best practice to identify constant variables within our code and explicitly mark them as const. The use of const and let clearly indicates to the reader of the code the intent of the variable. A variable marked as const cannot be changed, and a variable declared with let is a block-scoped temporary variable.

Union types

TypeScript allows us to express a type as a combination of two or more other types. These types are known as union types, and they use the pipe symbol ( | ) to list all of the types that will make up this new type. Consider the following code:

function printObject(obj: string | number) {
    console.log(`obj = ${obj}`);
}
printObject(1);
printObject("string value");

Here, we have defined a function named printObject that has a single parameter named obj. Note how we have specified that the obj parameter can be either of type string or of type number by listing them as a union type with a pipe separator. The last two lines of the code call this function with a number, and then with a string. The output of this code is as follows:

obj = 1
obj = string value

Here, we can see that the printObject function will work with either a string or a number.

Type guards

When working with union types, the compiler will still apply its strong typing rules to ensure type safety. As an example of this, consider the following code:

function addWithUnion(
    arg1: string | number,
    arg2: string | number
) {
    return arg1 + arg2;
}

Here, we have defined a function named addWithUnion that accepts two parameters and returns their sum. The arg1 and arg2 parameters are union types, and can therefore hold either a string or a number. Unfortunately, this code will generate the following error:

error TS2365: Operator '+' cannot be applied to types 'string | number' and 'string | number'

What the compiler is telling us here is that it cannot tell what type it should use when it attempts to add arg1 to arg2. Is it supposed to add a string to a number, or a string to a string? As we discussed in Chapter 1, Up and Running Quickly, the effects of adding a string and a number in JavaScript can lead to unwanted results.

This is where type guards come in. A type guard is an expression that performs a check on our type, and then guarantees that type within its scope. Let's re-write our previous function with a type guard as follows:

function addWithTypeGuard(
    arg1: string | number,
    arg2: string | number
) {
    if (typeof arg1 === "string") {
        // arg 1 is treated as a string
        console.log(`arg1 is of type string`);
        return arg1 + arg2;
    }
    if (typeof arg1 === "number" && typeof arg2 === "number") {
        // both are numbers
        console.log(`arg1 and arg2 are numbers`);
        return arg1 + arg2;
    }
    console.log(`default return treat both as strings`)
    return arg1.toString() + arg2.toString();
}

Here, we have added two if statements within the body of our code. The first if statement uses the JavaScript typeof keyword to test what type the arg1 argument is. The typeof operator will return a string depending on what the value of the argument is at runtime. This can be one of the following possible values: "number", "string", "boolean", "object", or "undefined". If the type of the arg1 argument is a string, then the first code block will execute. Within this code block, the compiler knows that arg1 is of type string, and will therefore treat arg1 to be of type string within the code block. Our type guard, therefore, is the code block after the check for the type of string.

Our second if statement has two typeof checks and is checking whether both the arg1 and arg2 arguments are of type number. If they are both numbers, then both arg1 and arg2 are treated as type number within the code block. This type guard, therefore, will treat both the arg1 and arg2 arguments as type number within this code block.

Let's test this function as follows:

console.log(` "1", "2" = ${addWithTypeGuard("1", "2")}`);
console.log(`  1 ,  2  = ${addWithTypeGuard(1, 2)}`);
console.log(`  1 , "2" = ${addWithTypeGuard(1, "2")}`);

Here, we call the addWithTypeGuard function three times: once with both arguments of type string, once with both arguments of type number, and the third time with a number and a string. The output of this code is as follows:

arg1 is of type string
 "1", "2" = 12
arg1 and arg2 are numbers
  1 ,  2  = 3
default return treat both as strings
  1 , "2" = 12

Here, we can see that our first call to the addWithTypeGuard function is using two arguments that are strings. The code identifies the first argument as being of type string and therefore enters the first if statement block.

The concatenation of the string "1" with the string "2" results in the string "12". The second call to the addWithTypeGuard function uses two numbers as arguments, and our code therefore identifies both arguments as numbers, and as such adds the value 1 and the value 2, resulting in 3. The third call to the addWithTypeGuard function uses a number as the first argument and a string as the second. The code therefore falls through to our default code, and treats both arguments as strings.

Type aliases

TypeScript introduces the concept of a type alias, where we can create a named type that can be used as a substitute for a type union. Type aliases can be used wherever normal types are used and are denoted by using the type keyword, as follows:

type StringOrNumber = string | number;
function addWithTypeAlias( 
    arg1: StringOrNumber,
    arg2: StringOrNumber
    ) {
    return arg1.toString() + arg2.toString();
}

Here, we have defined a type alias named StringOrNumber by using the type keyword and assigning a type union of string or number to it. We then use this StringOrNumber type in our function definition for the addWithTypeAlias function. Note that both the arg1 and arg2 arguments are of type StringOrNumber, which will allow us to call this function with either strings or numbers.

Type aliases are a handy way of specifying a type union and giving it a name, and are particularly useful when the type union is used over and over again.

Enums

Enums are a special type whose concept is similar to other languages such as C#, C++, or Java, and provides the solution to the problem of special numbers, or special strings. Enums are used to define a human-readable name for a specific number or string. Consider the following code:

enum DoorState {
    Open,
    Closed
}
function checkDoorState(state: DoorState) {
    console.log(`enum value is : ${state}`);
    switch (state) {
        case DoorState.Open:
            console.log(`Door is open`);
            break;
        case DoorState.Closed:
            console.log(`Door is closed`);
            break;
    }
}

Here, we start by using the enum keyword to define an enum named DoorState. This enum has two possible values, either Open or Closed. We then have a function named checkDoorState that has a single parameter named state, of type DoorState. This means that the correct way to call this function is with one of the values that the DoorState enum provides us. This function starts by logging the actual value of the state parameter to the console, and then executes a switch statement. This switch statement simply logs a message to the console depending on the value of the state parameter that was passed in.

We can now run this code as follows:

checkDoorState(DoorState.Open);
checkDoorState(DoorState.Closed);

Here, we are calling the checkDoorState function, once for each possible value within the DoorState enum. The output of this code is as follows:

enum value is : 0
Door is open
enum value is : 1
Door is closed

Here, we can clearly see that the compiler has generated a numerical value for each of our defined enum values. The numerical value for the enum value DoorState.Open is 0, and likewise, the numerical value of DoorState.Closed has been set to 1. This all occurs under the hood.

Using enums helps us to provide a clear set of values for a variable or function parameter. They also provide a tried and tested way of eliminating so called magic numbers by defining a limited number of possible values.

One last note on enums is that we can set the numerical value of an enum value to whatever we like, as shown in the following code:

enum DoorStateSpecificValues {
    Open = 3,
    Closed = 7,
    Unspecified = 256
}

Here, we have defined an enum named DoorStateSpecificValues that has three possible values, Open, Closed, and Unspecified. We have also overridden the default values for this enum such that the Open value will be 3, the Closed value will be 7, and the Unspecified value will be 256.

String enums

A further variant of the enum type is what is known as a string enum, where the numerical values are replaced with strings, as follows:

enum DoorStateString {
    OPEN = "Open",
    CLOSED = "Closed"
}
console.log(`OPEN = ${DoorStateString.OPEN}`);

Here, we have an enum named DoorStateString and have replaced the numerical values with string values for each of the defined enum values. We then log a message to the console with the value of the DoorStateString.OPEN enum. The output of this code is as follows:

OPEN = Open

As expected, the compiler is resolving the enum value of DoorStateString.OPEN to the "Open" string.

Const enums

The final variant of the enum family is called the const enum, which adds the const keyword before the enum definition, as follows:

const enum DoorStateConst {
    Open = 10,
    Closed = 20
}
console.log(`const Closed = ${DoorStateConst.Open}`);

Here, we have defined a const enum named DoorStateConst, which has provided two possible values. We then log the value of the DoorStateConst.Open enum value to the console.

const enums have been introduced for performance reasons. To see what happens under the hood, we will need to view the JavaScript that this code produces. Firstly, let's take a look at the JavaScript implementation of the DoorState enum that we were discussing earlier. As the DoorState enum has not been marked as const, its JavaScript implementation is as follows:

var DoorState;
(function (DoorState) {
    DoorState[DoorState["Open"] = 0] = "Open";
    DoorState[DoorState["Closed"] = 1] = "Closed";
})(DoorState || (DoorState = {}));

Here, we have some pretty complex-looking JavaScript. We will not discuss this implementation here, but instead we'll take a look at what this structure becomes when we examine it in a debugger, such as the one in VSCode, as shown in the following screenshot:

Figure 2.1: VSCode debugging window showing the internal structure of an enum

Here, we are viewing the object named DoorState within the VSCode debugger. We can see the DoorState object has four properties, named Closed, Open, 0, and 1. It also has a number of functions that have been attached to the object prototype, including a constructor and the hasOwnProperty and toString functions, to name a few. The purpose of this exercise was to show that when we create an enum, the compiler will generate a fully fledged JavaScript object, complete with properties and functions for the enum's implementation.

Let's now look at the generated JavaScript for a const enum:

console.log("const Closed = " + 10 /* Open */);

Here, we find that there is no actual implementation of the enum itself at all. The compiler has simply substituted the JavaScript code of 10 /* Open */ wherever we have used the const enum value of DoorStateConst.Open. This reduces the size of code that is generated, as the JavaScript runtime does not need to work with a full-blown JavaScript object in order to check a value.