So far, you've experimented with variables in TypeScript that have flexible types, such as those created with type unions. However, there are some instances where this flexibility can lead to problems. For instance, when a variable has the type unknown or any, TypeScript can't guarantee type safety for operations carried out on it. At this point, you need to employ a method referred to as type narrowing. This technique is integral, particularly when you're dealing with variables of uncertain types and need to identify a more specific type for these variables. Let's delve deeper into situations in TypeScript where type narrowing is suitable, and explore how to implement it.
Why is type narrowing necessary?
TypeScript cannot accurately identify the methods or properties applicable to a variable when it is declared with an unspecified type, like a union type, any, null, or unknown. This challenge arises because the variable could potentially represent any type within the union or any kind at all in the case of any.
Let's consider you have a function called printLength designed to print the length of a text string. But, the parameter text is defined as a union type string | null, implying it can be either a string or null:
function printLength(text: string | null) {
console.log(text.length); //Error: 'text' is possibly 'null'.
}This union type in TypeScript poses a problem since a string and null type permit different operations. Specifically, you can determine the length of a string, but you can't do the same with null.
A similar situation applies to the following code:
function getFirstElement(array: Array<number> | undefined) {
console.log(array[0]); // Error: Object is possibly 'undefined'.
}Here, the function getFirstElement is set to retrieve the first element of an array. The parameter array can be either an Array<number> or undefined. (Here, Array is a generic type, and number is the type argument, meaning the array can contain elements of type number.)
Similarly, if you attempt to access the first element of array without dealing with the undefined type, TypeScript will throw an error. It's because indexing on undefined is not valid, resulting in a compile error.
As you might have noticed, the main problem in these scenarios is the uncertainty of types. The solution for both unknown and null types is the same: you need to explicitly check the type and specify the exact type of a variable at a specific point in your code. It's precisely what you'll do with type narrowing, to lower the risk of errors and increase the overall reliability of your code.
Applying type narrowing: example practices
Let's revisit our initial example concerning the printLength function. We can use type narrowing via a conditional check (if (text !== null)) to ensure type safety and avoid type errors by determining if text is not null:
function printLength(text: string | null) {
if (text !== null) {
console.log(text.length); // Safe to access length as text is not null
} else {
console.log("Text is null");
}
}If
textis notnull, TypeScript narrows down its type tostringwithin theifclause. Therefore, it is safe to accesstext.length, aslengthis a valid property of a string.If
textisnull, theelseclause handles this scenario, preventing any unsafe operations onnull.
In this function, implementing type narrowing ensures that the code only accesses length when it's clear that text is a string.
In the case of the function getFirstElement, the error occurred because the parameter variable array could either be an array of numbers or be undefined. To address this, before trying to access the first element, we must verify if array is not undefined. This verification can be done using the if (array !== undefined) statement as follows:
function getFirstElement(array: Array<number> | undefined) {
if (array !== undefined) {
console.log(array[0]); // Safe as array is not undefined
} else {
console.log("Array is undefined");
}
}If
arrayis indeed an array (notundefined), it is safe to accessarray[0]. With TypeScript's assurance thatarraycontains elements, trying to get the first element won't return an error.If
arrayisundefined, theelseclause catches this, preventing an attempt to access an element fromundefined.
Using this approach, we can prevent type errors when trying to perform operations on undefined, like accessing an element, or else these errors may keep surfacing.
In both solutions, the central principle is safety; that is, to check for potential "problematic" types (null or undefined) before doing operations invalid for those types. This type narrowing practice allows TypeScript to correctly determine the more specific type within each conditional block, thus carrying out safe operations and preventing both runtime and compile-time errors.
A brief look at type guards
A type guard in TypeScript is a handy method that refines the type of a variable within a certain code area. It operates like a safety inspection in an application, ensuring you handle the correct data type.
One of the built-in mechanisms in TypeScript for making type guards is the typeof operator. This operator verifies a variable's type at runtime, returning a string revealing its operand's type.
Let's see how you can apply the typeof operator to overcome the challenges in our previous examples, starting with our printLength function:
function printLength(text: string | null) {
if (typeof text === "string") {
console.log(text.length);
} else {
console.log("Text is null");
}
}Here, the typeof operator directly checks if text is a string, unlike our previous type narrowing approach. In TypeScript, this kind of test is handy for type narrowing, ensuring that, within the if block, text is treated as a string and not null.
For our getFirstElement function, we apply Array.isArray() as a different type guard, changing from typeof. This method determines if the value is an array, providing true if it is and false otherwise:
function getFirstElement(array: Array<number> | undefined) {
if (Array.isArray(array)) {
console.log(array[0]); // Safe as array is an array
} else {
console.log("Array is undefined");
}
}
In the code above, the Array.isArray(array) method ensures that 'array' is not only defined but indeed an array. It's essential because 'array' might potentially be something else, particularly if the function signature or 'array' type alters.
Although TypeScript features numerous type guards, in this section, our solution only required two of them: typeof and Array.isArray(). These guards aid us in accurately pinning down variable types and provide more dependable type checking than fundamental conditionals, reducing the possibility of runtime errors.
Please remember that this introductory topic emphasized straightforward type narrowing methods for clarity. More advanced techniques exist, such as user-defined and built-in type guards. We'll delve deeper into these strategies in future topics, as they offer greater accuracy and flexibility in managing variables with uncertain types.
Conclusion
Type narrowing is a crucial method that allows you to use variables accurately, especially when their exact types are unclear. By implementing type narrowing techniques, like conditional checks, you instruct TypeScript to recognize more specific types in various parts of your code. This helps to avoid runtime errors and render your code more predictable. This method is fundamental to crafting reliable and type-safe TypeScript code.