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.