Home Programming Mastering TypeScript - Fourth Edition

Mastering TypeScript - Fourth Edition

By Nathan Rozentals
ai-assist-svg-icon Book + AI Assistant
eBook + AI Assistant $43.99 $29.99
Print $54.99
Subscription $15.99 $10 p/m for three months
ai-assist-svg-icon NEW: AI Assistant (beta) Available with eBook, Print, and Subscription.
ai-assist-svg-icon NEW: AI Assistant (beta) Available with eBook, Print, and Subscription. $10 p/m for first 3 months. $15.99 p/m after that. Cancel Anytime! ai-assist-svg-icon NEW: AI Assistant (beta) Available with eBook, Print, and Subscription.
What do you get with a Packt Subscription?
Gain access to our AI Assistant (beta) for an exclusive selection of 500 books, available during your subscription period. Enjoy a personalized, interactive, and narrative experience to engage with the book content on a deeper level.
This book & 7000+ ebooks & video courses on 1000+ technologies
60+ curated reading lists for various learning paths
50+ new titles added every month on new and emerging tech
Early Access to eBooks as they are being written
Personalised content suggestions
Customised display settings for better reading experience
50+ new titles added every month on new and emerging tech
Playlists, Notes and Bookmarks to easily manage your learning
Mobile App with offline access
What do you get with a Packt Subscription?
This book & 6500+ ebooks & video courses on 1000+ technologies
60+ curated reading lists for various learning paths
50+ new titles added every month on new and emerging tech
Early Access to eBooks as they are being written
Personalised content suggestions
Customised display settings for better reading experience
50+ new titles added every month on new and emerging tech
Playlists, Notes and Bookmarks to easily manage your learning
Mobile App with offline access
What do you get with eBook + Subscription?
Download this book in EPUB and PDF formats, plus a monthly download credit
This book & 6500+ ebooks & video courses on 1000+ technologies
60+ curated reading lists for various learning paths
50+ new titles added every month on new and emerging tech
Early Access to eBooks as they are being written
Personalised content suggestions
Customised display settings for better reading experience
50+ new titles added every month on new and emerging tech
Playlists, Notes and Bookmarks to easily manage your learning
Mobile App with offline access
What do you get with a Packt Subscription?
Gain access to our AI Assistant (beta) for an exclusive selection of 500 books, available during your subscription period. Enjoy a personalized, interactive, and narrative experience to engage with the book content on a deeper level.
This book & 6500+ ebooks & video courses on 1000+ technologies
60+ curated reading lists for various learning paths
50+ new titles added every month on new and emerging tech
Early Access to eBooks as they are being written
Personalised content suggestions
Customised display settings for better reading experience
50+ new titles added every month on new and emerging tech
Playlists, Notes and Bookmarks to easily manage your learning
Mobile App with offline access
What do you get with eBook?
Along with your eBook purchase, enjoy AI Assistant (beta) access in our online reader for a personalized, interactive reading experience.
Download this book in EPUB and PDF formats
Access this title in our online reader
DRM FREE - Read whenever, wherever and however you want
Online reader with customised display settings for better reading experience
What do you get with video?
Download this video in MP4 format
Access this title in our online reader
DRM FREE - Watch whenever, wherever and however you want
Online reader with customised display settings for better learning experience
What do you get with video?
Stream this video
Access this title in our online reader
DRM FREE - Watch whenever, wherever and however you want
Online reader with customised display settings for better learning experience
What do you get with Audiobook?
Download a zip folder consisting of audio files (in MP3 Format) along with supplementary PDF
What do you get with Exam Trainer?
Flashcards, Mock exams, Exam Tips, Practice Questions
Access these resources with our interactive certification platform
Mobile compatible-Practice whenever, wherever, however you want
ai-assist-svg-icon NEW: AI Assistant (beta) Available with eBook, Print, and Subscription. ai-assist-svg-icon NEW: AI Assistant (beta) Available with eBook, Print, and Subscription. BUY NOW $10 p/m for first 3 months. $15.99 p/m after that. Cancel Anytime! ai-assist-svg-icon NEW: AI Assistant (beta) Available with eBook, Print, and Subscription.
eBook + AI Assistant $43.99 $29.99
Print $54.99
Subscription $15.99 $10 p/m for three months
What do you get with a Packt Subscription?
Gain access to our AI Assistant (beta) for an exclusive selection of 500 books, available during your subscription period. Enjoy a personalized, interactive, and narrative experience to engage with the book content on a deeper level.
This book & 7000+ ebooks & video courses on 1000+ technologies
60+ curated reading lists for various learning paths
50+ new titles added every month on new and emerging tech
Early Access to eBooks as they are being written
Personalised content suggestions
Customised display settings for better reading experience
50+ new titles added every month on new and emerging tech
Playlists, Notes and Bookmarks to easily manage your learning
Mobile App with offline access
What do you get with a Packt Subscription?
This book & 6500+ ebooks & video courses on 1000+ technologies
60+ curated reading lists for various learning paths
50+ new titles added every month on new and emerging tech
Early Access to eBooks as they are being written
Personalised content suggestions
Customised display settings for better reading experience
50+ new titles added every month on new and emerging tech
Playlists, Notes and Bookmarks to easily manage your learning
Mobile App with offline access
What do you get with eBook + Subscription?
Download this book in EPUB and PDF formats, plus a monthly download credit
This book & 6500+ ebooks & video courses on 1000+ technologies
60+ curated reading lists for various learning paths
50+ new titles added every month on new and emerging tech
Early Access to eBooks as they are being written
Personalised content suggestions
Customised display settings for better reading experience
50+ new titles added every month on new and emerging tech
Playlists, Notes and Bookmarks to easily manage your learning
Mobile App with offline access
What do you get with a Packt Subscription?
Gain access to our AI Assistant (beta) for an exclusive selection of 500 books, available during your subscription period. Enjoy a personalized, interactive, and narrative experience to engage with the book content on a deeper level.
This book & 6500+ ebooks & video courses on 1000+ technologies
60+ curated reading lists for various learning paths
50+ new titles added every month on new and emerging tech
Early Access to eBooks as they are being written
Personalised content suggestions
Customised display settings for better reading experience
50+ new titles added every month on new and emerging tech
Playlists, Notes and Bookmarks to easily manage your learning
Mobile App with offline access
What do you get with eBook?
Along with your eBook purchase, enjoy AI Assistant (beta) access in our online reader for a personalized, interactive reading experience.
Download this book in EPUB and PDF formats
Access this title in our online reader
DRM FREE - Read whenever, wherever and however you want
Online reader with customised display settings for better reading experience
What do you get with video?
Download this video in MP4 format
Access this title in our online reader
DRM FREE - Watch whenever, wherever and however you want
Online reader with customised display settings for better learning experience
What do you get with video?
Stream this video
Access this title in our online reader
DRM FREE - Watch whenever, wherever and however you want
Online reader with customised display settings for better learning experience
What do you get with Audiobook?
Download a zip folder consisting of audio files (in MP3 Format) along with supplementary PDF
What do you get with Exam Trainer?
Flashcards, Mock exams, Exam Tips, Practice Questions
Access these resources with our interactive certification platform
Mobile compatible-Practice whenever, wherever, however you want
  1. Free Chapter
    Exploring the Type System
About 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.
Publication date:
April 2021
Publisher
Packt
Pages
538
ISBN
9781800564732

 

Exploring the Type System

Thus far, we have covered the basic principles of using types in TypeScript. We already know how to define a type for a variable or a function argument, and some of the rules that the compiler uses in order to ensure strongly typed code. We have discussed some of the basic, or primitive, types (number, string, and boolean), as well as the array type. However, we have only just skimmed the surface of the TypeScript type system.

In this chapter, we will explore all of the primitive types that are available, as well as some language elements that we can use to better describe how to use these types. We will discuss when and where to use these types and language elements, and even when not to use some types.

This chapter is broken up into five main sections. In the first section, we will explore the any type, the let and const keywords, and union types. The concept of union types feeds directly into a discussion on type guards, and type aliases. We will round this first section off with a discussion on enums, including string enums and const enums.

The second section of this chapter will work through the remainder of the primitive types that are available for use, including undefined, null, object, and unknown. There are also language features that help us when we have to work with values that could be undefined or null, including optional chaining, nullish coalescing, and definite assignment. We will also discuss the never type, and how it can be used to identify logic errors.

In the third section of this chapter, we will discuss the object spread syntax, which is used to combine properties of one object with the properties of another. We will see how this spread syntax can also be used with arrays as a handy syntax for combining arrays and array values.

The fourth section of this chapter is all about tuples, what they are, and how they can be used.

The fifth and final section of this chapter deals with the use of types within functions and function signatures. We will see how we can use optional parameters in a function, default parameters, and rest syntax. We will also show how we can define a function signature as a function parameter, in order to ensure that any function provided as a callback function has the correct parameters and parameter types.

There is a lot of ground to cover in this chapter. It will, however, give us a great understanding of the TypeScript type system. We will explore what types are available for use, how and where these types can be used, and what language features are available to help with our use of types.

Let's explore the TypeScript type system.

 

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.

 

More primitive types

In the last chapter, we discussed a few of the basic, or primitive, types that are available in TypeScript. We covered numbers, strings, and booleans, which are part of the group of primitive types, and we also covered arrays. While these represent some of the most basic and widely used types in the language, there are quite a few more of these primitive types, including undefined, null, unknown, and never. Related to these primitive types, we also have some language features, such as conditional expressions and optional chaining, that provide a convenient short-hand method of writing otherwise rather long-winded code. We will explore the remainder of the primitive types, as well as these convenient language features, in this part of the chapter.

Undefined

There are a range of circumstances where the value of something in JavaScript is undefined. Let's take a look at an example of this, as follows:

let array = ["123", "456", "789"];
delete array[0];
for (let i = 0; i < array.length; i++) {
    console.log(`array[${i}] = ${array[i]}`);
}

Here, we start by declaring a variable that holds an array of strings named array. We then delete the first element of this array. Finally, we use a simple for loop to loop through the elements of this array and print the value of the array element to the console. The output of this code is as follows:

array[0] = undefined
array[1] = 456
array[2] = 789

As we can see, the array still has three elements, but the first element has been set to undefined, which is the result of deleting this array element.

In TypeScript, we can use the undefined type to explicitly state that a variable could be undefined, as follows:

for (let i = 0; i < array.length; i++) {
    checkAndPrintElement(array[i]);
}
function checkAndPrintElement(arrElement: string | undefined) {
    if (arrElement === undefined)
        console.log(`invalid array element`);
    else
        console.log(`valid array element : ${arrElement}`);
}

Here, we are looping through our array and calling a function named checkAndPrintElement. This function has a single parameter named arrayElement, and is defined as allowing it to be of type string or undefined. Within the function itself, we are checking if the array element is, in fact, undefined, and are logging a warning message to the console. If the parameter is not undefined, we simply log its value to the console. The output of this code is as follows:

invalid array element
valid array element : 456
valid array element : 789

Here, we can see the two different messages being logged to the console.

The undefined type, therefore, allows us to explicitly state when we expect a variable to be undefined. We are essentially telling the compiler that we are aware that a variable may not yet have been defined a value, and we will write our code accordingly.

Null

Along with undefined, JavaScript also allows values to be set to null. Setting a value to null is intended to indicate that the variable is known, but has no value, as opposed to undefined, where the variable has not been defined in the current scope. Consider the following code:

function printValues(a: number | null) {
    console.log(`a = ${a}`);
}
printValues(1);
printValues(null);

Here, we have defined a function named printValues, which has a single parameter named a, which can be of type number or of type null. The function simply logs the value to the console. We then call this function with the values of 1 and null. The output of this code is as follows:

a = 1
a = null

Here, we can see that the console logs match the input values that we called the printValues function with. Again, null is used to indicate that a variable has no value, as opposed to the variable not being defined in the current scope.

The use of null and undefined has been debated for many years, with some arguing that null is not really necessary and that undefined could be used instead. There are others that argue the exact opposite, stating that null should be used in particular cases. Just remember that TypeScript will warn us if it detects that a value could be null, or possibly undefined, which can help to detect unwanted issues with our code.

Conditional expressions

One of the features of newer JavaScript versions that we are able to use in the TypeScript language is a simple, streamlined version of the if then else statement, which uses a question mark ( ? ) symbol to define the if statement and a colon ( : ) to define the then and else path. These are called conditional expressions. The format of a conditional expression is as follows:

(conditional) ? ( true statement )  :  ( false statement );

As an example of this syntax, consider the following code:

const value : number = 10;
const message : string = value > 10 ?
    "value is larger than 10" : "value is 10 or less";
console.log(message);

Here, we start by declaring a variable named value, of type number, that is set to the value of 10. We then create a variable named message, which is of type string, and uses the conditional expression syntax to check whether the value of the value variable is greater than 10. The output of this code is as follows:

value is 10 or less

Here, we can see that the message variable has been set to the string value of "value is 10 or less", because the value > 10 conditional check returned false.

Conditional expressions are a very handy syntax to use in place of the long-winded syntax we would normally have to use in order to code a simple if then else statement.

Conditional expressions can be chained together, so either the truth statement or the false statement, or both, can include another conditional expression.

Optional chaining

When using object properties in JavaScript, and in particular nested properties, it is important to ensure that a nested property exists before attempting to access it. Consider the following JavaScript code:

var objectA = {
    nestedProperty: {
        name: "nestedPropertyName"
    }
}
function printNestedObject(obj) {
    console.log("obj.nestedProperty.name = "
        + obj.nestedProperty.name);
}
printNestedObject(objectA);

Here, we have an object named objectA that has a nested structure. It has a single property named nestedProperty, which holds a child object with a single property called name. We then have a function called printNestedObject that has a single parameter named obj, which will log the value of the obj.nestedProperty.name property to the console. We then invoke the printNestedObject function and pass in objectA as the single argument. The output of this code is as follows:

obj.nestedProperty.name = nestedPropertyName

As expected, the function works correctly. Let's now see what happens if we pass in an object that does not have the nested structure that we were expecting, as follows:

console.log("calling printNestedObject");
printNestedObject({});
console.log("completed");

The output of this code is as follows:

calling printNestedObject
TypeError: Cannot read property 'name' of undefined
    at printNestedObject (javascript_samples.js:28:67)
    at Object.<anonymous> (javascript_samples.js:32:1)

Here, our code has logged the first message to the console, and has then caused a JavaScript runtime error. Note that the final call to log the "completed" message to the console has not even executed, as the entire program crashed while attempting to read the 'name' property on an object that is undefined.

This is obviously a situation to avoid, and it can actually happen quite often. This sort of nested object structure is most often seen when working with JSON data. It is best practice to check that the properties that you are expecting to find are actually there, before attempting to access them. This results in code that's similar to the following:

function printNestedObject(obj: any) {
    if (obj != undefined
        && obj.nestedProperty != undefined
        && obj.nestedProperty.name) {
        console.log(`name = ${obj.nestedProperty.name}`)
    } else {
        console.log(`name not found or undefined`);
    }
}

Here, we have modified our printNestedObject function, which now starts with a long if statement. This if statement first checks whether the obj parameter is defined. If it is, it then checks if the obj.nestedProperty property is defined, and finally if the obj.nestedProperty.name property is defined. If none of these return undefined, the code prints the value to the console. Otherwise, it logs a message to state that it was unable to find the whole nested property.

This type of code is fairly common when working with nested structures, and must be put in place to protect our code from causing runtime errors.

The TypeScript team, however, have been hard at work in driving a proposal in order to include a feature named optional chaining into the ECMAScript standard, which has now been adopted in the ES2020 version of JavaScript. This feature is best described through looking at the following code:

function printNestedOptionalChain(obj: any) {
    if (obj?.nestedProperty?.name) {
        console.log(`name = ${obj.nestedProperty.name}`)
    } else {
        console.log(`name not found or undefined`);
    }
}

Here, we have a function named printNestedOptionalChain that has exactly the same functionality as our previous printNestedObject function. The only difference is that the previous if statement, which consisted of three lines, is now reduced to one line. Note how we are using the ?. syntax in order to access each nested property. This has the effect that if any one of the nested properties returns null or undefined, the entire statement will return undefined.

Let's test this theory by calling this function as follows:

printNestedOptionalChain(undefined);
printNestedOptionalChain({
    aProperty: "another property"
});
printNestedOptionalChain({
    nestedProperty: {
        name: null
    }
});
printNestedOptionalChain({
    nestedProperty: {
        name: "nestedPropertyName"
    }
});

Here, we have called our printNestedOptionalChain function four times. The first call sets the entire obj argument to undefined. The second call has provided a valid obj argument, but it does not have the nestedProperty property that the code is looking for. The third call has the nestedProperty.name property, but it is set to null. Finally, we call the function with a valid object that has the nested structure that we are looking for. The output of this code is as follows:

name not found or undefined
name not found or undefined
name not found or undefined
name = nestedPropertyName

Here, we can see that the optional chaining syntax will return undefined if any of the properties within the property chain is either null or undefined.

Optional chaining has been a much-anticipated feature, and the syntax is a welcome sight for developers who are used to writing long-winded if statements to ensure that code is robust and will not fail unexpectedly.

Nullish coalescing

As we have just seen, it is a good idea to check that a particular variable is not either null or undefined before using it, as this can lead to errors. TypeScript allows us to use a feature of the 2020 JavaScript standard called nullish coalescing, which is a handy shorthand that will provide a default value if a variable is either null or undefined. Consider the following code:

function nullishCheck(a: number | undefined | null) {
    console.log(`a : ${a ?? `undefined or null`}`);
}
nullishCheck(1);
nullishCheck(null);
nullishCheck(undefined);

Here, we have a single function named nullishCheck that accepts a single parameter named a that can be either a number, undefined, or null. This function then logs the value of the a variable to the console, but uses a double question mark ( ?? ), which is the nullish coalescing operator. This syntax provides an alternative value, which is provided on the right hand side of the operator, to use if the variable on the left hand side is either null or undefined. We then call this function three times, with the values 1, null, and undefined. The output of this code is as follows:

a : 1
a : undefined or null
a : undefined or null

Here, we can see that the first call to the nullishCheck function provides the value 1, and this value is printed to the console untouched. The second call to the nullishCheck function provides null as the only argument, and therefore the function will substitute the string undefined or null in place of the value of a. The third call uses undefined, and as we can see, the nullish check will fail over to undefined or null in this case as well.

We can also use a function on the right-hand side of the nullish coalescing operator, or indeed a conditional statement as well, as long as the type of the value returned is correct.

Null or undefined operands

TypeScript will also apply its checks for null or undefined when we use basic operands, such as add ( + ), multiply ( * ), divide ( / ), or subtract ( - ). This can best be seen using a simple example, as follows:

function testNullOperands(a: number, b: number | null | undefined) {
    let addResult = a + b;
}

Here, we have a function named testNullOperands that accepts two parameters. The first, named a, is of type number. The second parameter, named b, can be of type number, null, or undefined. The function creates a variable named addResult, which should hold the result of adding a to b. This code will, however, generate the following error:

error TS2533: Object is possibly 'null' or 'undefined'

This error occurs because we are trying to add two values, and one of them may not be a numeric value. As we have defined the parameter b in this function to be of type number, null, or undefined, the compiler is picking up that we cannot add null to a number, nor can we add undefined to a number, hence the error.

A simple fix to this function may be to use the nullish coalescing operator as follows:

function testNullOperands(a: number, b: number | null | undefined) {
    let addResult = a + (b ?? 0);
}

Here, we are using the nullish coalescing operator to substitute the value of 0 for the value of b if b is either null or undefined.

Definite assignment

Variables in JavaScript are defined by using the var keyword. Unfortunately, the JavaScript runtime is very lenient on where these definitions occur, and will allow a variable to be used before it has been defined. Consider the following JavaScript code:

console.log("aValue = " + aValue);
var aValue = 1;
console.log("aValue = " + aValue);

Here, we start by logging the value of a variable named aValue to the console. Note, however, that we only declare the aValue variable on the second line of this code snippet. The output of this code will be as follows:

aValue = undefined
aValue = 1

As we can see from this output, the value of the aValue variable before it had been declared is undefined. This can obviously lead to unwanted behavior, and any good JavaScript programmer will check that a variable is not undefined before attempting to use it. If we attempt the same thing in TypeScript, as follows:

console.log(`lValue = ${lValue}`);
var lValue = 2;

The compiler will generate the following error:

error TS2454: Variable 'lValue' is used before being assigned

Here, the compiler is letting us know that we have possibly made a logic error by using the value of a variable before we have declared the variable itself.

Let's consider another, more tricky case of where this could happen, where even the compiler can get things wrong, as follows:

var globalString: string;
setGlobalString("this string is set");
console.log(`globalString = ${globalString}`);
function setGlobalString(value: string) {
    globalString = value;
}

Here, we start by declaring a variable named globalString, of type string. We then call a function named setGlobalString that will set the value of the globalString variable to the string provided. Then, we log the value of the globalString variable to the console. Finally, we have the definition of the setGlobalString function that just sets the value of the globalString variable to the parameter named value. This looks like fairly simple, understandable code, but it will generate the following error:

error TS2454: Variable 'globalString' is used before being assigned

According to the compiler, we are attempting to use the value of the globalString variable before it has been given a value. Unfortunately, the compiler does not quite understand that by invoking the setGlobalString function, the globalString variable will actually have been assigned a value before we attempt to log it to the console.

To cater for this scenario, as the code that we have written will work correctly, we can use the definite assignment assertion syntax, which is to append an exclamation mark (!) after the variable name that the compiler is complaining about. There are actually two places to do this.

Firstly, we can modify the code on the line where we use this variable for the first time, as follows:

console.log(`globalString = ${globalString!}`);

Here, we have placed an exclamation mark after the use of the globalString variable, which has now become globalString!. This will tell the compiler that we are overriding its type checking rules, and are willing to let it use the globalString variable, even though it thinks it has not been assigned.

The second place that we can use the definite assignment assertion syntax is in the definition of the variable itself, as follows:

var globalString!: string;

Here, we have used the definite assignment assertion operator on the definition of the variable itself. This will also remove the compilation error.

While we do have the ability to break standard TypeScript rules by using definite assignment operators, the most important question is why? Why do we need to structure our code in this way? Why are we using a global variable in the first place? Why are we using the value of a variable where if we change our logic, it could end up being undefined? It certainly would be better to refactor our code so that we avoid these scenarios.

The only place that the author has found where it makes sense to use definite assignment is when writing unit tests. In a unit test scenario, we may be testing the boundaries of a specific code path, and are purposefully bending the rules of TypeScript in order to write a particular test. All other cases of using definite assignment should really warrant a review of the code to see if it can be structured in a different way.

Object

TypeScript introduces the object type to cover types that are not primitive types. This includes any type that is not number, boolean, string, null, symbol, or undefined. Consider the following code:

let structuredObject: object = {
    name: "myObject",
    properties: {
        id: 1,
        type: "AnObject"
    }
}
function printObjectType(a: object) {
    console.log(`a: ${JSON.stringify(a)}`);
}

Here, we have a variable named structuredObject that is a standard JavaScript object, with a name property, and a nested property named properties. The properties property has an id property and a type property. This is a typical nested structure that we find used within JavaScript, or a structure returned from an API call that returns JSON. Note that we have explicitly typed this structuredObject variable to be of type object.

We then define a function named printObjectType that accepts a single parameter, named a, which is of type object. The function simply logs the value of the a parameter to the console. Note, however, that we are using the JSON.stringify function in order to format the a parameter into a human-readable string. We can then call this function as follows:

printObjectType(structuredObject);
printObjectType("this is a string");

Here, we call the printObjectType function with the structuredObject variable, and then attempt to call the printObjectType function with a simple string. This code will produce an error, as follows:

error TS2345: Argument of type '"this is a string"' is not assignable to parameter of type 'object'.

Here, we can see that because we defined the printObjectType function to only accept a parameter of type object, we cannot use any other type to call this function. This is due to the fact that object is a primitive type, similar to string, number, boolean, null, or undefined, and as such we need to conform to standard TypeScript typing rules.

Unknown

TypeScript introduces a special type into its list of basic types, which is the type unknown. The unknown type can be seen as a type-safe alternative to the type any. A variable marked as unknown can hold any type of value, similar to a variable of type any. The difference between the two, however, is that a variable of type unknown cannot be assigned to a known type without explicit casting.

Let's explore these differences with some code as follows:

let a: any = "test";
let aNumber: number = 2;
aNumber = a;

Here, we have defined a variable named a that is of type any, and set its value to the string "test". We then define a variable named aNumber, of type number, and set its value to 2.

We then assign the value of a, which is the string "test", to the variable aNumber. This is allowed, since we have defined the type of the variable a to be of type any. Even though we have assigned a string to the a variable, TypeScript assumes that we know what we are doing, and therefore will allow us to assign a string to a number.

Let's rewrite this code but use the unknown type instead of the any type, as follows:

let u: unknown = "an unknown";
u = 1;
let aNumber2: number;
aNumber2 = u;

Here, we have defined a variable named u of type unknown, and set its value to the string "an unknown". We then assign the numeric value of 1 to the variable u. This shows that the unknown type mimics the behavior of the any type in that it has relaxed the normal strict type checking rules, and therefore this assignment is allowed.

We then define a variable named aNumber2 of type number and attempt to assign the value of the u variable to it. This will cause the following error:

error TS2322: Type 'unknown' is not assignable to type 'number'

This is a very interesting error, and highlights the differences between the any type and the unknown type. While the any type in effect relaxes all type checking, the unknown type is a primitive type and follows the same rules that are applied to any of the primitive types, such as string, number, or boolean.

This means that we must cast an unknown type to another primitive type before assignment. We can fix the preceding error as follows:

aNumber2 = <number>u;

Here, we have used explicit casting to cast the value of u from type unknown to type number. Because we have explicitly specified that we are converting an unknown type to a number type, the compiler will allow this.

Using the unknown type forces us to make a conscious decision when using these values. In essence, we are letting the compiler know that we know what type this value should be when we actually want to use it. This is why it is seen as a type-safe version of any, as we need to use explicit casting to convert an unknown type into a known type before using it.

Never

The final primitive type in the TypeScript collection is a type of never. This type is used to indicate instances where something should never occur. Even though this may sound confusing, we can often write code where this occurs. Consider the following code:

function alwaysThrows() {
    throw new Error("this will always throw");
    return -1;
}

Here, we have a function named alwaysThrows, which will, according to its logic, always throw an error. Remember that once a function throws an error, it will immediately return, and no other code in the function will execute. This means that the second line of this function, which returns a value of -1, will never execute.

This is where the never type can be used to guard against possible logic errors in our code. Let's change the function definition to return a type of never, as follows:

function alwaysThrows(): never {
    throw new Error("this will always throw");
    return -1;
}

With the addition of the return type of never for this function, the compiler will now generate the following error:

error TS2322: Type '-1' is not assignable to type 'never'

This error message is clearly telling us that the function, which returns a type of never, is attempting to return the value of -1. The compiler, therefore, has identified a flaw in our logic.

Never and switch

A more advanced use of the never type can be used to trap logic errors within switch statements. Consider the following code:

enum AnEnum {
    FIRST,
    SECOND
}
function getEnumValue(enumValue: AnEnum): string {
    switch (enumValue) {
        case AnEnum.FIRST: return "First Case";
    }
    let returnValue: never = enumValue;
    return returnValue;
}

Here, we start with a definition of an enum named AnEnum, which has two values, FIRST and SECOND. We then define a function named getEnumValue, which has a single parameter named enumValue of type AnEnum and returns a string. The logic within this function is pretty simple and is designed to return a string based on the enumValue passed in.

Note, however, that the switch statement only has a case statement for the FIRST value of the enum, but does not have a case statement for the SECOND value of the enum. This code, therefore, will not work correctly if we call the function with AnEnum.SECOND.

This is where the last two lines of this function come in handy. The error message that is generated for this code is as follows:

error TS2322: Type 'AnEnum.SECOND' is not assignable to type 'never'

Let's take a closer look at this code. After our switch statement, we define a variable named returnValue, which is of type never. The trick in this code is that we assign the value of the incoming parameter, enumValue, which is of type AnEnum, to the returnValue variable, which is of type never. This statement is generating the error.

The TypeScript compiler, then, is examining our code, and determining that there is a case statement missing for the AnEnum.SECOND value. In this case, the logic falls through the switch statement, and then attempts to assign the AnEnum.SECOND value to a variable of type never, hence the error.

This code can be easily fixed, as follows:

function getEnumValue(enumValue: AnEnum): string {
    switch (enumValue) {
        case AnEnum.FIRST: return "First Case";
        case AnEnum.SECOND: return "Second Case";
    }
    let returnValue: never = enumValue;
    return returnValue;
}

Here, we have simply added the missing case statement to handle the AnEnum.SECOND value. With this in place, the error is resolved. While this may be fairly easy to spot in a simple example like this, this sort of error is commonplace when working with large code bases. Over time, developers often add values to an enum to get their unit tests to work, but can easily miss these missing case statements. Using the never type here safeguards our code so that we can pick up these errors earlier.

 

Object spread

When working with basic JavaScript objects, we often need to copy the properties of one object to another, or do some mixing and matching of properties from various objects. In TypeScript, we can use an ES7 technique known as object spread to accomplish this. Consider the following code:

let firstObj: object = { id: 1, name: "firstObj" };
let secondObj: object = { ...firstObj };
console.log(`secondObj : ${JSON.stringify(secondObj)}`);

Here, we have defined a variable named firstObj that is of type object and has an id property and a name property. We then define a variable named secondObj and use the object spread syntax of three dots ( ... ) to assign a value to it. The value we are assigning is an object that is made up of the firstObj variable, that is { ...firstObj }. The output of this code is as follows:

secondObj : {"id":1,"name":"firstObj"}

Here, we can see that the id and name properties and values have been copied into the new secondObj variable.

We can also use this technique to combine multiple objects together. Consider the following code:

let nameObj: object = { name: "nameObj name" };
let idObj: object = { id: 1 };
let obj3 = { ...nameObj, ...idObj };
console.log(`obj3 = ${JSON.stringify(obj3)}`);

Here, we have define a variable named nameObj that has a single property called name. We then have a variable named idObj that has a single property named id. Note how we are using the spread syntax to create a variable named obj3 that is the result of combining the properties of nameObj and the properties of the idObj variables. The output of this code is as follows:

obj3 = {"name":"nameObj name","id":1}

This output shows us that the properties of both objects have been merged into the obj3 variable, using the object spread syntax.

Spread precedence

When using object spread, properties will be copied incrementally. In other words, if two objects have a property with the same name, then the object that was specified last will take precedence. As an example of this, consider the following:

let objPrec1: object = { id: 1, name: "obj1 name" };
let objPrec2: object = { id: 1001, desc: "obj2 description" };
let objPrec3 = { ...objPrec1, ...objPrec2 };
console.log(`objPrec3 : ${JSON.stringify(objPrec3, null, 4)}`);

Here, we have defined two variables named objPrec1 and objPrec2. Both of these objects have an id property; however, objPrec1 has a name property, and objPrec2 has a desc property. We then create a variable named objPrec3 that is a combination of these two objects. Finally, we print the value of the objPrec3 object to the console. The output of this code is as follows:

objPrec3 : {
    "id": 1001,
    "name": "obj1 name",
    "desc": "obj2 description"
}

Here, we can see that the spread operator has combined the properties of both original objects into the objPrec3 variable. This new object has all three properties, id, name, and desc. Note that the id property was common between both original objects, and that the value of 1001 has taken precedence in this case, as it has been taken from the object that was specified last.

Spread with arrays

Interestingly, the spread syntax can also be used with arrays. Consider the following code:

let firstArray = [1, 2, 3];
let secondArray = [3, 4, 5];
let thirdArray = [...firstArray, ...secondArray];
console.log(`third array = ${thirdArray}`);

Here, we have defined two arrays, named firstArray and secondArray. We then use the spread syntax to combine these two arrays into another variable named thirdArray. We then print the value of the thirdArray variable to the console. The output of this code is as follows:

third array = 1,2,3,3,4,5

Here, we can see that the contents of the two arrays have been combined into the thirdArray variable. Interestingly, the new array contains the value 3 twice, as it was present in both arrays. Note that this syntax can be used on arrays of any type.

The spread syntax can also appear in any order. Consider the following code:

let objArray1 = [
    { id: 1, name: "first element" },
]
let objArray2 = [
    { id: 2, name: "second element" }
]
let objArray3 = [
    ...objArray1,
    { id: 3, name: "third element" },
    ...objArray2
]
console.log(`objArray3 = ${JSON.stringify(objArray3, null, 4)}`);

Here, we have defined two arrays named objArray1 and objArray2, each with a single array element, that has both an id property and a name property. We then create a third variable named objArray3, which uses object spread to create a third array. Note that we are building the objArray3 array out of the objArray1 array, then adding an element, and then including the contents of the ojbArray2 array. The output of this code is as follows:

objArray3 = [
    {
        "id": 1,
        "name": "first element"
    },
    {
        "id": 3,
        "name": "third element"
    },
    {
        "id": 2,
        "name": "second element"
    }
]

Here, we can see that the objArray3 variable contains all of the elements of both the objArray1 and objArray2 arrays, as well as the element with id : 3 , and name : "third element" that we injected into the middle of the array using spread syntax.

 

Tuples

Tuples are a method of defining a type that has a finite number of unnamed properties, with each property having an associated type. When using a tuple, all of the properties must be provided. This can best be explained in an example, as follows:

let tuple1: [string, boolean];
tuple1 = ["test", true];
tuple1 = ["test"];

Here, we have defined a variable named tuple1, whose type is defined as an array of types. The first type is a string, and the second type is a boolean. We then assign a value to the tuple1 variable that contains an array with two values, the first of type string and the second of type boolean.

Note that the last line of this code attempts to assign a value to the tuple1 variable that does not have all of the properties that are required. This last line will generate an error as follows:

error TS2741: Property '1' is missing in type '[string]' but required in type '[string, boolean]'

What this error is telling us is that the number and types defined in a tuple must be provided when we assign anything to a tuple.

Tuple destructuring

As tuples use the array syntax, they can be destructured or disassembled in two ways. The first way of destructuring a tuple uses the simple array syntax, as follows:

console.log(`tuple1[0] : ${tuple1[0]}`);
console.log(`tuple1[1] : ${tuple1[1]}`);

Here, we are logging the values of the tuple1 variable to the console by referencing its index within the array, that is, tuple1[0] and tuple1[1]. The output of this code is as follows:

tuple1[0] : test
tuple1[1] : true

Here, we can see that we can access each of the values in the tuple by using the array destructuring syntax. Note that the compiler knows that there are only two elements of this array, and if we attempt to access the third value within this tuple, that is, tuple1[2], the compiler will generate an error.

Another way of destructuring a tuple is to use the array syntax to create an array of named elements and then assign the value of the tuple to this variable, as follows:

let [tupleString, tupleBoolean] = tuple1;
console.log(`tupleString = ${tupleString}`);
console.log(`tupleBoolean = ${tupleBoolean}`);

Here, we have used the array syntax to create a tuple out of two variable names, that is, tupleString and tupleBoolean. We then assign the value of our original tuple, that is, tuple1, to this array of named variables. We can then use these named variables instead of needing to access them using the standard array syntax, that is, tuple1[0]. The output of this code is as follows:

tupleString = test
tupleBoolean = true

Here, we can see that the tuple has been correctly destructured into our two named variables, tupleString and tupleBoolean.

Using the named variable syntax to destructure tuples is a better way of constructing your code, as you can name the variable according to how it will be used. We will see some practical examples of using tuples in Chapter 9, Using Observables to Transform Data, where we use the RxJS library to initiate multiple API calls to retrieve JSON data.

Optional tuple elements

Note that tuple elements can be marked optional by using the question mark (?) after the type, as follows:

let tupleOptional: [string, boolean?];
tupleOptional = ["test", true];
tupleOptional = ["test"];
console.log(`tupleOptional[0] : ${tupleOptional[0]}`);
console.log(`tupleOptional[1] : ${tupleOptional[1]}`);

Here, we have defined a tuple named tupleOptional that consists of two elements, a string, and an optional boolean value. We then assign the value of ["test", true] to this tuple, and then we assign just the value ["test"] to this tuple. As the second element has been marked as optional, we do not need to specify it. We then log the values of the tuple elements to the console, using array syntax. The output of this code is as follows:

tupleOptional[0] : test
tupleOptional[1] : undefined

Here, we can see that the tuple value at index 0 has been set to the value of "test", but that the tuple value at index 1 is undefined as it was not specified in our last assignment statement.

Tuples and spread syntax

We are also able to use spread syntax to define a tuple that can have a variable number of elements. Consider the following code:

let tupleRest: [number, ...string[]];
tupleRest = [1];
tupleRest = [1, "string1"];
tupleRest = [1, "string1", "string2"];

Here, we are using spread syntax to indicate that the variable named tupleRest has a number element, followed by a variable number of string elements. We then assign values to this tuple, starting with a single numerical value, and then a numerical value and a variable number of string values. All of these assignments are valid.

Object destructuring

In a similar way to tuples, standard objects can be also be destructured. Consider the following example:

let complexObject = {
    aNum: 1,
    bStr: "name",
    cBool: true
}
let { aNum, bStr, cBool } = complexObject;

Here, we have defined an object named complexObject that has three properties, aNum, bStr, and cBool. Each of these properties has been assigned a value. We then destructure this object into three separate variables, named aNum, bStr, and cBool, in a similar manner to how we destructured tuples. We can now use these variables as follows:

console.log(`aNum : ${aNum}`);
console.log(`bStr : ${bStr}`);
console.log(`cBool : ${cBool}`);

Here, we are using the aNum, bStr, and cBool variables that we created when destructuring the complexObject object. The output of this code is as follows:

aNum : 1
objId : 1
objName : name

As we can see from this output, we are able to destructure simple objects into a series of variables, which allows us to access the value of these properties through our standard variable naming syntax.

Note that we are also able to rename the variable names during the destructuring step as follows:

let { aNum: objId, bStr: objName, cBool: isValid } 
    = complexObject;
console.log(`objId : ${objId}`);
console.log(`objName : ${objName}`);
console.log(`isValid : ${isValid}`);

Here, we are destructuring the complexObject into a series of variables. Note the use of the colon (:) in this example. We are using the colon to rename the aNum property into the objId variable, using the syntax aNum: objId. Similarly, the bStr property is renamed to a variable named objName, and the cBool property is renamed to a variable named isValid. The colon (:) symbol as used here is not specifying a type as it normally would, but instead is used to rename the variable name used in destructuring.

 

Functions

In this section of the chapter, we will take a look at functions and their definitions, and how the TypeScript language can be used to introduce further type safety whenever functions are used. Functions can use many of the concepts that we have already discussed, including optional parameters and spread syntax. We will also discuss how we can define a function signature in such a manner that if a function defines another function as a parameter, we can make sure that the function we pass in has the correct parameters. Finally, we will take a look at how to define function overrides.

Optional parameters

Similar to how we have seen tuples using optional elements, we can specify that a function can have optional elements in the same way, using the question mark (?). Consider the following code:

function concatValues(a: string, b?: string) {
    console.log(`a + b = ${a + b}`);
}
concatValues("first", "second");
concatValues("third");

Here, we have defined a function named concatValues that has two parameters, a and b, both of type string. The second argument, b, however, has been marked as optional using the question mark after the argument name, that is, b?: string. We then call this function with two parameters, and then with only a single parameter. The output of this code is as follows:

a + b = firstsecond
a + b = thirdundefined

Here, we can see that the first call to the concatValues function concatenates the strings "first" and "second", logging the value of "firstsecond" to the console. The second call to the concatValues function only provided a value for the first argument, as the second argument was marked as optional.

This second call to the concatValues function produces the output "thirdundefined", as we have not specified a value for the second argument. This means that the argument b was not been specified and is thus undefined.

Note that any optional parameters must be listed last in the parameter list of the function definition. You can have as many optional parameters as you like, as long as non-optional parameters precede the optional parameters.

Default parameters

A variant of the optional parameter syntax allows us to specify a default value for a parameter, if it has not been supplied. Consider the following code:

function concatWithDefault(a: string, b: string = "default") {
    console.log(`a + b = ${a + b}`);
}
concatWithDefault("first", "second");
concatWithDefault("third");

Here, we have defined a function named concatWithDefault that has two parameters, a and b, both of type string. Note, however, the definition of the parameter named b. We are assigning the value of "default" to this parameter within the function definition. This assignment will automatically make this parameter optional, and we do not use the question mark syntax to define this parameter as optional. Note, too, that the use of the explicit type for the parameter b, as in :string, is also optional, as the compiler will infer the type from the default value, which in this case is type string.

We then call this function with two arguments, and then with just a single argument. The output of this code is as follows:

a + b = firstsecond
a + b = thirddefault

Here, we can see that when we supply two arguments to the concatWithDefault function, the function will concatenate the arguments as expected. When we only supply a single argument, the second argument will default to the value "default".

Rest parameters

Interestingly, the parameters specified in a JavaScript function are all optional. Even if a JavaScript function specifies parameters in its function definition, we do not need to supply them when calling the function. In a quirky twist of the language, even if we do not specify any parameters in a function definition, we can still access the values that were provided when the function was invoked. Consider the following JavaScript code:

function testArguments() {
    for (var i = 0; i < arguments.length; i++) {
        console.log("argument[" + i + "] = " + arguments[i]);
    }
}
testArguments(1, 2);
testArguments("first", "second", "third");

Here, we have defined a JavaScript function named testArguments that does not specify any parameters. We then create a for loop to loop through the values of an array named arguments. If an array element is found, we log the value of the array element to the console. All JavaScript functions automatically have access to a special variable, named arguments, that can be used to retrieve all of the arguments that were used when the function is invoked.

We then invoke the testArguments function twice, once with the arguments 1 and 2, and the second time with the arguments "first", "second", and "third".

The output of this code is as follows:

argument[0] = 1
argument[1] = 2
argument[0] = first
argument[1] = second
argument[2] = third

Here, we can see a log of the arguments that were used to invoke the testArguments function. The first time we invoked the function, we used the arguments of 1 and 2. The second time we invoked this function, we used the arguments of "first", "second", and "third".

In order to express the equivalent function definition in TypeScript, we will need to use rest syntax, as follows:

function testArguments(...args: string[] | number[]) {
    for (let i in args) {
        console.log(`args[${i}] = ${args[i]}`);
    }
}
testArguments("1");
testArguments(10, 20);

Here, we have defined a function named testArguments using rest syntax, that is, the three dots ( ... ), to specify that the function can be called with any number of parameters. We are also using a type union here to indicate that the variable parameters can be of type string or of type number.

We then invoke the testArguments function with one argument, which is the string "1", and then invoke it with two numbers, namely 10 and 20. The output of this code is as follows:

args[0] = 1
args[0] = 10
args[1] = 20

Here, we can see that the testArguments function can be called with multiple arguments, and because the function definition allows these parameters to be either of type string or of type number, we are able to mimic the functionality of the earlier JavaScript function.

Function callbacks

One of the most powerful features of JavaScript, and in fact the technology that NodeJS was built on, is the concept of callback functions. A callback function is a function that is passed in as an argument to another function, and is then generally invoked within the original function. In other words, we are calling a function and telling it to go and do what it needs to do, and when it is finished, to call the function that we have supplied.

Just as we can pass a value into a function, we can also pass a function into a function as one of its arguments.

This is best illustrated by taking a look at some JavaScript code, as follows:

var myCallback = function (text) {
    console.log("myCallback called with " + text);
}
function withCallbackArg(message, callbackFn) {
    console.log("withCallback called, message : " + message);
    callbackFn(message + " from withCallback");
}
withCallbackArg("initial text", myCallback);

Here, we start with a function named myCallback that accepts a single parameter named text. It simply logs the value of the text argument to the console. We then define a function named withCallbackArg, which has two parameters, named message and callbackFn. This function logs a message to the console using the message argument, and then invokes the function passed in as the callbackFn parameter. When invoking the function passed in, it invokes it with some text indicating that it was called within the withCallback function.

Finally, we invoke the withCallbackArg function with two arguments. The first argument is the text string of "initial text", and the second argument is the myCallback function itself. The output of this code is as follows:

withCallback called, message : initial text
myCallback called with initial text from withCallback

As we can see from this output, the withCallbackArg function is being invoked and logging the "withCallback called, message : initial text" message to the console. It is then invoking the function that we passed into it as a callback function, which is the myCallback function.

Unfortunately, JavaScript cannot tell until it executes this code whether the second argument passed into the withCallbackArg function is actually a function. Let's test this theory by passing in a string for the callbackFn parameter, instead of an actual function, as follows:

withCallbackArg("text", "this is not a function");

Here, we are invoking the withCallbackArg function with two string values, instead of a string value and a function signature, as the function is expecting. The output of this code is as follows:

withCallback called, message : text
TypeError: callbackFn is not a function
    at withCallbackArg (javascript_samples.js:75:5)
    at Object.<anonymous> (javascript_samples.js:80:1)
    at Module._compile (internal/modules/cjs/loader.js:1133:30)

Here, we can see that we have caused a JavaScript runtime exception to occur, because the second argument that we passed into the withCallbackArg function was not a function, it was just a string.

JavaScript programmers, therefore, need to be careful when working with callbacks. The most useful technique for avoiding this sort of runtime error is to check if the argument passed in is actually a function using typeof, similarly to how we used typeof when creating type guards. This leads to a lot of defensive code being written to ensure that when a function is expecting a function to be passed in as a callback, it really is a function, before attempting to invoke it.

Function signatures as parameters

TypeScript uses its strong typing rules to ensure that if we define a function that needs a callback function, we can ensure that this function is provided correctly. In order to specify that a function parameter must be a function signature, TypeScript introduces the fat arrow syntax, or () =>, to indicate a function signature. Let's rewrite our previous JavaScript code using this syntax as follows:

function myCallback(text: string): void {
    console.log(`myCallback called with ${text}`);
}
function withCallbackArg(
    message: string,
    callbackFn: (text: string) => void
) {
    console.log(`withCallback called, message : ${message}`);
    callbackFn(`${message} from withCallback"`);
}

Here, we have defined a strongly typed function named myCallback that has a single parameter named text, which is of type string, and returns void. We have then defined a strongly typed function named withCallbackArg that also has two parameters. The first parameter is named message and is of type string, and the second parameter, named callbackFn, is using the fat arrow syntax, as follows:

callbackFn: (text: string) => void

This syntax defines the callbackFn parameter as being a function that accepts a single parameter of type string, and returns void.

We can then use this withCallbackArg function as follows:

withCallbackArg("initial text", myCallback);
withCallbackArg("text", "this is not a function");

Here, we have invoked the withCallbackArg function twice: once legitimately, by providing a string and a function as arguments, and once in error, by providing two strings as arguments. This code will produce the following error:

error TS2345: Argument of type '"this is not a function"' is not assignable to parameter of type '(text: string) => void'

Here, we can clearly see that the compiler will not allow us to invoke the withCallbackArg function if we do not provide the second argument as a function with a signature that matches our function definition.

This is a very powerful feature of TypeScript. With its strong typing rules, it is preventing us from providing callback functions that do not conform to the correct function signature. Again, this helps to catch errors at the time of compilation, and not further down the line when the code needs to be actually run and tested.

Function overrides

TypeScript provides an alternative to union types when defining a function and allows a function signature to provide different parameter types. Consider the following code:

function add(a: string, b: string): string;
function add(a: number, b: number): number;
function add(a: any, b: any) {
    return a + b;
}
add("first", "second");
add(1, 2);

Here, we have defined a function definition named add that accepts two parameters, named a and b, which are both of type string, and returns a string. We have then defined another function with the same name, add, that accepts two parameters named a and b that are of type number, which returns a number. Note that neither of these function definitions has an actual function implementation.

Finally, we define a function, again with the name of add, that accepts two parameters named a and b but that are of type any. This function definition also provides a function implementation, which simply returns the addition of the a and b arguments.

This technique is used to provide what are known as function overrides. We can call this function with two arguments of type string, or two arguments of type number, as follows:

add("first", "second");
add(1, 2);
add(true, false);

Here, we have invoked the add function with three types of arguments. Firstly, we invoke it with two arguments of type string. We then invoke it with two arguments of type number. Finally, we invoke the add function with two arguments of type boolean. This last line of code will generate the following error:

 error TS2769: No overload matches this call.
  Overload 1 of 2, '(a: string, b: string): string', gave the following error.
    Argument of type 'true' is not assignable to parameter of type 'string'.
  Overload 2 of 2, '(a: number, b: number): number', gave the following error.
    Argument of type 'true' is not assignable to parameter of type 'number'.

Here, we can see that the only valid function signatures are where the arguments a and b are both of type string, or where the arguments a and b are both of type number. Even though our final function definition uses the type of any, this function definition is not made available and is simply used for the function implementation. We therefore cannot invoke this function with two boolean arguments, as the error shows.

Literals

TypeScript also allows us to use what are known as literals, which are almost a hybrid of enums and type aliases. A literal will limit the allowed values to a set of values specified. A literal can be made of string, number, or boolean values. Consider the following code:

type AllowedStringValues = "one" | "two" | "three";
type AllowedNumericValues = 1 | 20 | 65535;
function withLiteral(input: 
    AllowedStringValues | AllowedNumericValues) {
    console.log(`called with : ${input}`);
}

Here, we have defined a literal named AllowedStringValues, as well as a literal named AllowedNumericValues. The syntax used for literals is very similar to the syntax of a type alias, where we use the type keyword followed by a set of allowed values. Unlike type aliases, however, we are not specifying a set of different types. We are specifying a set of allowed values, which is similar in concept to an enum.

We then have a function named withLiteral that accepts a single parameter of type AllowedStringValues, or of type AllowedNumericValues. This function simply logs the value of the input argument to the console. We can now use this function as follows:

withLiteral("one")
withLiteral("two");
withLiteral("three");
withLiteral(65535);
withLiteral("four");
withLiteral(2);

Here, we are invoking the withLiteral function with six values, namely "one", "two", "three", 65535, "four", and 2. Our literals, however, will only allow the values of "one", "two", "three", 1, 20, and 65535. As such, the last two lines of this code will generate the following errors:

error TS2345: Argument of type '"four"' is not assignable to parameter of type '1 | 20 | "one" | "two" | "three" | 65535'.
 error TS2345: Argument of type '2' is not assignable to parameter of type '1 | 20 | "one" | "two" | "three" | 65535'.

These error messages are generated because our literals do not allow the value "four" or the value 2 to be used.

Literals provide us with another tool that we can use when we need to define a function that accepts a standard string, number, or boolean, but where we need to limit the values provided to a defined set of values.

This concludes our exploration of the use of functions and function definitions with regard to the strong typing that TypeScript provides. We have discussed optional parameters, default parameters, rest syntax, function signatures, and function overrides. We also explored literals and how they can be used to limit the values allowed for function arguments.

 

Summary

In this chapter, we have taken a deep dive into the remainder of the primitive types that TypeScript makes available, such as any, null, undefined, object, and never. We covered a diverse range of language constructs, including the let keyword, optional chaining, nullish coalescing, object rest and spread, and tuples. We finished the chapter with a discussion on function definitions, and showed how we can use a variety of language constructs to accurately specify function parameters. In the next chapter, we will explore object-oriented programming techniques, including the use of classes, interfaces, inheritance, and modules.

About the Author
  • Nathan Rozentals

    Nathan Rozentals has been writing commercial software for over 30 years, in C, C++, Java and C#. He picked up TypeScript within a week after its initial release in October 2012 and realized how much TypeScript could help when writing JavaScript. He was one of the first people to start blogging about TypeScript, discussing early frameworks such as Backbone, Marionette, ExtJS and AngularJs. He knew he'd hit the mark when Microsoft staff started to reference his blog posts in their CodePlex discussion forums. Nathan’s TypeScript solutions now control User Interfaces in IoT devices, run as stand-alone applications for Point-of-Sale solutions, provide complex application configuration web sites, and are used for mission-critical server APIs.

    Browse publications by this author
Latest Reviews (3 reviews total)
Looks good, but I have not yet had time to read it.
It's good deal to purchase this book and to study it.
Mastering TypeScript - Fourth Edition
Unlock this book and the full library FREE for 7 days
Start now