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.