Union Types
Literal Types
A literal type is a type whose only value is a literal.
Here is how we could define a literal type 'Todo'
:
type TodoType = 'Todo';
Here is how we can use it:
let todo: TodoType = 'Todo';
Note that we can't assign any other value to todo
including other strings.
For example this is not possible:
let todo: TodoType = 'Done';
// This will result in a type error
We could also skip declaring the type alias and just use the literal type directly:
let todo: 'Todo' = 'Todo';
It should be noted that if we declare a variable, then by default TypeScript will infer it as a string
.
This makes sense since we could change the variable later:
let todo = 'Todo';
// Without the explicit type annotation, todo is a string
However if we declare a constant, then by default TypeScript will infer a literal type. This also makes sense since we can't change the constant later:
const todo = 'Todo';
// Even without the explicit type annotation,
// todo now has the literal type 'Todo'
While literal types by themselves are not very helpful, they are extremely useful in the context of union types.
Unions of Literal Types
A union type is a type that represents a value which may be one of multiple values.
Consider a type TaskState
which represents one of the following states:
- Todo
- InProgress
- Done
Here is how we would define the TaskState
type:
type TaskState = 'Todo' | 'InProgress' | 'Done';
The TaskState
type is a union type and each of the literal types 'Todo'
, 'InProgress'
and 'Done'
is a member of the union.
A variable of type TaskState
can only be of one of these literal types, i.e. it can only have one of the respective values.
For example these are all valid:
const state: TaskState = 'Todo';
const state2: TaskState = 'InProgress';
const state3: TaskState = 'Done';
But this is not:
const invalidState: TaskState = 'Dropped';
Other Union Types
We can also declare unions of arbitrary types.
The general syntax for declaring a union type is Type1 | Type2 | Type3 | ...
and a value of this union type can have the type Type1
or Type2
or Type3
etc.
The various types Type1
, Type2
, Type3
etc are called members of the union.
One particularly common union type is T | undefined
, for example:
function getTaskId(taskName: string): string | undefined {
// Implementation here
}
This function takes a taskName
and returns the corresponding ID.
Because we might discover that no task with the given name is present, we return either a string
or undefined
.
Working with a Union Type
It should be clear by now how we can define a union type, but how can we work with a union type? Consider the following function:
function logTaskName(taskName: string | undefined) {
console.log({
taskName,
taskNameLength: taskName.length,
});
}
Compiling this example will result in the following error:
index.ts:4:21 - error TS18048: 'taskName' is possibly 'undefined'.
4 taskNameLength: taskName.length,
~~~~~~~~
Found 1 error in index.ts:4
TypeScript will only allow to do something with the value of a union type if that something is valid for every member of the union.
Since taskName
can be either a string
or undefined
, we can't access .length
on it, because .length
is not a valid property of undefined
!
Instead we need to perform type narrowing where we narrow the type of a variable with code.
Basically, TypeScript can look at our code and try to understand that in certain code parts a value of a union type can only have the type of a particular member of the union.
The simplest way of narrowing a type is equality narrowing.
Here we can use the ===
or !==
operators to narrow a type.
Consider this example:
function logTaskName(taskName: string | undefined) {
if (taskName !== undefined) {
console.log({
taskName,
taskNameLength: taskName.length,
});
} else {
console.log('the task is not defined');
}
}
We narrow the type of taskName
in the taskName !== undefined
branch.
TypeScript will inspect our code and realize that since taskName
had the string | undefined
type and taskName !== undefined
in the truthy branch of the if statement, taskName
must be of type string
inside that branch (there is simply no other way).
Similarly, in the falsy branch of the if statement (i.e. the else
branch), TypeScript will know that taskName
must be undefined
.
This example also showcases a very important concept: The same variable can have a different type depending on the part of the code we are. This is not the case in many other programming languages, where a variable will always have the same type once it has been initialized.
Another (similar) way of narrowing a type is truthiness narrowing. Here we use the fact that certain values are truthy or falsy to narrow a type.
Consider this example:
function logTaskName(taskName: string | undefined | null) {
if (taskName) {
console.log({
taskName,
taskNameLength: taskName.length,
});
} else {
console.log('the task is not defined');
}
}
Since undefined
and null
are both falsy, the taskName
in the truthy branch of the if
statement can only have the type string
and we can use the .length
property.
However truthiness narrowing can lead to bugs and indeed the function logTaskName
has a subtle error.
Can you spot it?
That's right - it doesn't correctly handle the case of the empty string - after all, the empty string ''
is also falsy, therefore logTaskName("")
would print that the task not defined, which is probably not what we were going for.
We could fix the function like this:
function logTaskName(taskName: string | undefined | null) {
if (taskName === '') {
console.log('the task is empty');
} else if (taskName) {
console.log({
taskName,
taskNameLength: taskName.length,
});
} else {
console.log('the task is not defined');
}
}
You should generally be careful when relying on truthiness or falsiness. The way these concepts work in JavaScript can be a bit confusing and it's easy to miss an edge case.
Some people prefer to avoid these concepts altogether and instead provide explicit checks, for example:
function logTaskName(taskName: string | undefined | null) {
if (taskName !== undefined && taskName !== null) {
console.log({
taskName,
taskNameLength: taskName.length,
});
} else {
console.log('the task is not defined');
}
}
The last way of narrowing a type that we will discuss here is typeof
narrowing.
TypeScript knows how the typeof
operator works and you can use it to narrow a type as you would expect:
function processInput(value: string | number): number {
if (typeof value === 'string') {
// value must be a string here
return value.length;
} else {
// value must be a number here
return value;
}
}
The Non-Null Assertion Operator
You can use the non-null assertion operator to tell TypeScript that a value is definitely not undefined
or null
:
let input: string | undefined = 'Some string';
let trimmedInput: string = input!.trim();
Just as with type assertions, you should use this extremely sparingly and usually there is a better way.
Type Predicates
We can write user-defined type guards by utilizing type predicates. Consider the following example:
const array = ['Hello', undefined, 'World', undefined];
const filteredArray = array.filter((val) => val !== undefined);
Here array
is a (string | undefined)[]
and filteredArray
removes the undefined
elements.
However the inferred type of filteredArray
would still be (string | undefined)[]
because TypeScript can't easily inspect the contents of the filter function to realize that we remove the undefined
elements.
We could theoretically use a type assertion here:
const array = ['Hello', undefined, 'World', undefined];
const filteredArray = array.filter((val) => val !== undefined) as string[];
However instead of yelling at the TypeScript compiler that we know better, we can choose a better way and write a user-defined type guard:
function isString(val: string | undefined): val is string {
return typeof val === 'string';
}
The isString
function is a type guard, because it's return type is the type predicate val is string
.
Generally, a type predicate must have the form parameter is Type
where parameter
is the name of a parameter from the function signature.
We can use the type guard like this:
const array = ['Hello', undefined, 'World', undefined];
const filteredArray = array.filter(isString);
Now the inferred type of filteredArray
will be string[]
- and all that without using a single type assertion.
Hooray!
Discriminated Unions
A particularly important union type is the discriminated union. This is a union where a property is used to discriminate between union members. Consider the following classical example:
type Square = {
kind: 'square';
size: number;
};
type Rectangle = {
kind: 'rectangle';
width: number;
height: number;
};
type Shape = Square | Rectangle;
We can now narrow values of the discriminated union based on the discriminant property (which in this case is kind
):
function getArea(shape: Shape) {
if (shape.kind === 'square') {
// Here shape must be of type Square
return shape.size * shape.size;
} else {
// Here shape must be of type Rectangle
return shape.width * shape.height;
}
}