I'll be covering:
- What are Type Unions
- Type Guards and Control Flow Analysis
- 4.4's CFA for Aliased Conditions and Discriminants
Introduction
I've talked before about the pragmatism of TypeScript, augmenting JavaScript with type safety for an improved development experience. But it's much more than a thin layer of static types on top of JavaScript, it has an incredibly powerful type system. It's possible in TypeScript to derive new types from existing types using type unions, type intersections, mapped types and conditional types. This type programming capability allows us to cut down on code duplication and have a single source of truth when defining types.
interface Employee {
name: string
age: number;
dob: Date;
}
// Type Intersection
type EmployeeWithId = Employee & { id: string };
// This type will be equivalent to
// {
// id: string;
// name: string
// age: number;
// dob: Date;
// }
// Readonly uses Mapped Types
type ReadonlyEmployee = Readonly<Employee>;
// This type will be equivalent to
// {
// readonly name: string
// readonly age: number;
// readonly dob: Date;
// }
// A Mapped Type with a Conditional Type allows for interesting transformations
type DateToString<T> = {
[P in keyof T]: T[P] extends Date ? string : T[P];
};
type EmployeeJson = DateToString<Employee>
// This type will be equivalent to
// {
// name: string
// age: number;
// dob: string; // Date has changed to string
// }
In this post, I'm going to delve into Type Unions since TypeScript 4.4 augments the compiler with support for more natural use of union type checks. If you'd like a post on any of the items above, be sure to like and leave a comment.
Type Unions Are Awesome
Type Unions allow us to define a new type which represents any one of a set of possible types. For example:
type Primitive =
| string
| number
| boolean;
let x: Primitive;
Here, the type union Primitive
means that x
may refer to either a string
OR number
OR boolean
value.
x = 'Hello';
x = 123;
x = false;
x = new Date(); // Compiler Error
This is especially important in a JavaScript world where API’s frequently contain functions that accept
or return values of different types. Libraries exploit dynamic typing to compact many different behaviours
into the a single function.
With unions in TypeScript, instead of going to the highest abstraction of any
or unknown
we can be safe and restrict to a set of valid types.
But if x
can be any one of these possible types, what can we safely do with x
. It turns out, in this case,
not very much:
The auto-complete shows that all we can do is valueOf
, toString
and toLocaleString
.
The only APIs that we can safely use with a union are those that are common to ALL members of the union.
We can of course use a type assertion to a specific type:
console.log(x.toFixed(2)); // Compiler Error
const y = x as number;
console.log(y.toFixed(2)); // Valid
But how would we know that our assertion is correct, that the value is the correct type? Of course if we had any doubt, in JavaScript, we would write some code to test the data.
if (typeof x === 'number') {
const y = x as number;
console.log(y.toFixed(2)); // We can now safely use 'number' operations
}
TypeScript simplifies this kind of code by examining the control flow of our program (if
s, switch
es etc)
and automatically refines or narrows the type of the variable (what may be referred to as
a smart cast in other languages). So, inside the if
block, it automatically interprets
x
as a number
.
if (typeof x === 'number') {
console.log(x.toFixed(2)); // 'x' can safely use 'number' operations
}
What's more, on the else
block for this if
, the type of x
would be the remainder of the union. So, in this case, it
would be narrowed to string | boolean
.
What the compiler is doing here is called Control Flow Analysis (CFA) and the condition we have written is called a Type Guard.
The compiler can analyse control flow via if
, switch
, throw
, return
and operators
such as logic (&&
, ||
), assignment, equality and ternary (? :
).
The real elegance of this is that we are writing the same code we would write in JavaScript - notice that there are no type annotations above. The difference in TypeScript is we get type safety and better tooling.
Types of Type Guard
Using typeof
is just one guard example. Let's break down the types of type guards.
I've resisted categorising them as types of types of type guard was too hard to type. TYPE!
null
Guard
null
checking is one of the most common guards.
const element: HTMLElement | null = document.getElementById('target');
element.innerText = 'Hello'; // Compiler error - null in union
if (element !== null) {
element.innerText = 'Hello'; // null removed from union
}
Of course we could simplify this using optional chaining:
element?.innerText = 'Hello';
typeof
Guard
We've already seen the typeof
guard in action. Here's another example using a switch:
// Note that the type of 'primitive' in each branch is different
switch (typeof primitive) {
case "number": return primitive.toFixed(2);
case "string": return primitive.toUpperCase();
case "boolean": return !primitive;
default: throw new Error("Unexpected type")
}
Remember that typeof
can only tell us if a type is
string
, number
, boolean
, symbol
, function
, bigint
, undefined
. Everything else will return "object"
.
instanceof
Guard
With instanceof
we can test if an object is an instance of, or derived from, a class.
function setupInput(input: HTMLInputElement | HTMLTextAreaElement) {
input.value = ''; // Valid since value is common to both types
if (input instanceof HTMLTextAreaElement) {
// These properties are only valid for HTMLTextAreaElement
input.rows = 25;
input.cols = 80;
input.wrap = 'hard';
} else {
// These properties are only valid for HTMLInputElement
input.width = 400;
input.height = 50;
}
}
Note, all the property sets inside the if/else blocks would fail outside as they are not common to both types.
in
Guard
The in
operator allows us to test an object for the existence of a member.
In the code below, only HTMLTextAreaElement
has a rows
property so
by testing for its existence the compiler determines the type of the input
variable
on each branch and narrows the type.
function setupInput(input: HTMLInputElement | HTMLTextAreaElement) {
if ('rows' in input) {
// These properties are only valid for HTMLTextAreaElement
input.rows = 25;
input.cols = 80;
input.wrap = 'hard';
} else {
// These properties are only valid for HTMLInputElement
input.width = 400;
input.height = 50;
}
}
Note, in
can only be used if ALL members of the union are object types (not primitives).
Discriminated Union Guards
A Discriminated Union is a design where the types within a union each contain a member that uniquely identifies or allows the compiler to discriminate that type. For example:
interface Dog {
kind: 'Dog';
bark(): string;
}
interface Cat {
kind: 'Cat';
purr(): string;
}
interface Fox {
kind: 'Fox';
}
type Animal =
Dog
| Cat
| Fox;
Here, the kind
property is common to all types so we can access it on a variable of type Animal
.
TypeScript can use the comparison of this property with possible values to narrow the type appropriately.
// animal has type Animal or 'Dog | Cat | Fox'
const animal: Animal = readAnimal();
if (animal.kind === "Fox") {
// animal has type 'Fox'
throw new Error('What does the Fox say?');
}
// 'Fox' removed because of throw above
// animal has type 'Dog | Cat'
return animal.kind === "Cat"
? animal.purr() // animal has type 'Cat'
: animal.bark(); // animal has type 'Dog'
Again, the great thing here is that we have not written any type annotations or assertions but the compiler is still doing error checking and the IDE can give us lots of auto-complete and support.
User-Defined Guards
Prior to TypeScript 4.4, user-defined guards were the only way to improve the readability of this kind of code. User-defined guards allow us to break our type checking logic and guards out into named functions.
For example, consider the code to check our HTML element earlier:
if ('rows' in input) {
// input has type HTMLTextAreaElement
}
Quite often I would break a check like this out into a function, but the compiler will not drill into the function to find the type guard so narrowing does not take place.
function isTextArea(input: HTMLInputElement | HTMLTextAreaElement): boolean {
return 'rows' in input;
}
if (isTextArea(input)) {
// input still has union type
input.rows = 25; // Compiler error
}
What we must do is change the return type from boolean
to a type predicate. This allows us to provide the compiler
type information about an input parameter if our boolean function returns true
.
function isTextArea(input: HTMLInputElement | HTMLTextAreaElement): input is HTMLTextAreaElement {
return 'rows' in input;
}
if (isTextArea(input)) {
// input has type HTMLTextAreaElement
input.rows = 25; // Valid
}
Assertion Functions
We can use Assertion Functions in a similar way. An Assertion Function informs the compiler that it will throw an error if
some condition is not true. We can use them to assert that an input parameter has a certain type and then CFA will
narrow appropriately. The syntax is similar to a user-defined type guard except we prefix asserts
to the type predicate.
function assertIsTextArea(input: HTMLInputElement | HTMLTextAreaElement): asserts input is HTMLTextAreaElement {
if (!('rows' in input)) {
throw new Error("Expected a HTMLTextAreaElement");
}
}
assertIsTextArea(input);
// If we get to this line, no exception has been thrown
// Therefore, input has type HTMLTextAreaElement
input.rows = 25;
The advantage here is it removes indentation and still narrows. This can be really useful for developer assertions at the top of functions (older meaning of guards) or within tests.
What TypeScript 4.4 Improves
Although User-Defined Type Guards and Assertion Functions help with readability they are a coarse tool. What the TypeScript 4.4 compiler brings to the table is the ability to retain Control Flow Analysis information for variables so we can write more natural code and retain the narrowing functionality.
For example, the following code would not work prior to TypeScript 4.4 since the guard has been broken out to a variable:
const isTextArea = 'rows' in input;
if (isTextArea) {
// Narrowing has NOT taken place
input.rows = 25; // Compiler error
}
In TypeScript 4.4, this code just works. The variable isTextArea
retains the information that it denotes whether
input
is a HTMLTextAreaElement
and this can be picked up by CFA. It calls these Aliased Conditions.
The conditions can use all the guards described so far, can incorporate multiple conditions and even works transitively.
const isDog = animal.kind === 'Dog';
const isCat = animal.kind === 'Cat';
const canSpeak = isDog || isCat; // Also retains CFA information
if (!canSpeak) {
throw new Error('What does the Fox say?');
}
return isCat
? animal.purr()
: animal.bark(); // Without the throw above this would be `Fox | Dog`
The canSpeak
variable is an "or" of two other variables, but the type information from these two variables
is combined and applied to canSpeak
.
Limitations
The following limitations are not a criticism per se but merely my experimentation with the feature to understand it better.
Only Conditions are Aliased
This type information aliasing only works on conditions. If we create variables
to contain key information and later build conditions from these,
then the type guard behaviour does not work. For example, here we store the typeof
result in
a variable.
let primitiveType = typeof primitive;
switch (primitiveType) {
case "number": return primitive.toFixed(2); // No narrowing - compiler error
case "string": return primitive.toUpperCase(); // No narrowing - compiler error
//...
}
Only Top Level Variables
TypeScript is clever enough that normal type guards support narrowing with nested properties. For example:
interface InputArea {
control: HTMLTextAreaElement | HTMLInputElement;
}
interface Component {
inputArea: InputArea;
}
function setupComponent(component: Component) {
if ('rows' in component.inputArea.control) {
// These properties are only valid for HTMLTextAreaElement
component.inputArea.control.rows = 25;
component.inputArea.control.cols = 80;
component.inputArea.control.wrap = 'hard';
} else {
// These properties are only valid for HTMLInputElement
component.inputArea.control.width = 400;
component.inputArea.control.height = 50;
}
}
Here we have a type guard against property nested 2 levels into an object but the compiler still narrows it. However, this does not work with the new aliased conditions (even with a single level of nesting). It must be a top level variable.
function setupComponent(component: Component) {
let isUsingTextArea = 'rows' in component.inputArea.control;
if (isUsingTextArea) { // Narrowing does not occur
component.inputArea.control.rows = 25; // Compiler error
// ...
Transitive Depth Limit
There is also a limit to how far the compiler will apply the transitive rule. I was curious about what this limit was so wrote the following (horrible) code:
interface A {
kind: 'A';
child: Node;
}
interface B {
kind: 'B';
child: Node;
}
interface C {
kind: 'C';
child: Node;
}
interface D {
kind: 'D';
child: Node;
}
interface E {
kind: 'E';
child: Node;
}
interface F {
kind: 'F';
child: Node;
}
interface Terminal {
kind: 'Terminal';
}
type Node = A | B | C | D | E | F | Terminal;
declare const root: Node;
So we have a large discriminated union where all members have a child
property except one, Terminal
.
Next I created a series of conditions which each relied on the previous.
const isA = root.kind === 'A';
const isAorB = isA || root.kind === "B";
const isAorBorC = isAorB || root.kind === "C";
const isAorBorCorD = isAorBorC || root.kind === "D";
const isAorBorCorDorE = isAorBorCorD || root.kind === "E";
const isAorBorCorDorEorF = isAorBorCorDorE || root.kind === "F";
My goal was to see for which variable we lost the type guard information that ensure root
is
not the Terminal
type. What I found was that the following lines work as root
is known to have a child
:
const childIsA1 = isAorB && root.child.kind === 'A';
const childIsA2 = isAorBorC && root.child.kind === 'A';
const childIsA3 = isAorBorCorD && root.child.kind === 'A';
const childIsA4 = isAorBorCorDorE && root.child.kind === 'A';
and the following line does not as it does not know what root
is:
const childIsA5 = isAorBorCorDorEorF && root.child.kind === 'A';
// ^^^^^ - Error
Indicating 4 levels of nested aliasing. I can't see this being an issue - I was just curious and thought it would be fun to test.
Conclusion
I hope you find Type Guards as cool as I do. The updates for TypeScript 4.4 are not very flashy but this change is incredibly useful for keeping code clean and safe without adding lots of type annotations.
If you haven't already done so, check out my posts showing some of the useful features in TypeScript 4.1, TypeScript 4.2 and TypeScript 4.3.
Also, be sure to check out our TypeScript course. We're also happy to deliver Angular & React training using TypeScript. We deliver virtually to companies all over the world and are happy to customise our courses to tailor to your team's level and specific needs. Come and check us out to see if we can help you and your team.
About the Title Image
The title image for the post is of Brendan Gleeson, taken from a movie called 'The Guard'. It's a dark comedy about a policeman in Ireland. The police service in Ireland is called "An Garda Síochána" (The Guardians of the Peace in Irish) with its members referred to as 'Guards'. I like tenuous movie references.