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

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.