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.