Type Predicates and Assert Signatures
Two somewhat recent additions to TypeScript are very useful when checking the type of a value.
Type Predicates
Suppose you have a function that can take as its first parameter either of two types and needs to run two different branches of logic for each possibility:
interface Student {
name: string;
courses: Course[];
}
interface Course {
name: string;
id: string;
textbookISBNs: number[];
}
function getTextbooks(target: Student | Course): Textbook {
if (isStudent(target)) {
return target.courses // ERROR
.map(course => getTextbooks(course))
.reduce((prev, curr) => [...prev, ...curr])
} else {
return target.textbookISBNs // ERROR
.map(isbn => getTextbookByISBN(isbn))
.reduce((books, book) => [...books, book], [])
}
}
function isStudent(candidate: any): boolean {
return typeof candidate?.name === 'string' && Array.isArray(candidate?.courses);
}
function getTextbookByISBN(isbn: number): Textbook {
// Already implemented
}
Here, we get compilation errors, when trying to access the properties specific to Student
or Course
, even though we know we’re in a branch where target
is guaranteed to be of that type. We could use a type assertion to cast target
to Student
or Course
depending on the context, but that’s not ideal. What we need is a way to tell the type system that when isStudent
returns true
, the input is a Student
and otherwise, it is not a Student
. This is where Type Predicates come in:
function isStudent(candidate: any): candidate is Student {
return typeof candidate?.name === 'string' && Array.isArray(candidate?.courses);
}
Just by making that one change to the isStudent
function, TypeScript now knows that in the branch where isStudent
returned true
, target
is a Student
, and in the branch where it returned false
, it is not a Student
, which means that by process of elimintation, it is a Course
. We call this a Type Predicate
. Note that the predicate replaces the return type. It’s like we’re saying, “this function returns whether or not candidate
is a Student
.”
Using typeof
and instanceof
Guards
Note that for simple checks of typeof
(to check primitive types like string
) or instanceof
(to check if an object is an instance of a class), we don’t need to add a function such as isString()
just to get the benefits of type predicates for type checking purposes, because TypeScript will automagically know that the variable conforms to the type after using typeof
or instanceof
.
Example:
class Chef extends Person {
public favoriteCookingUtensil: Item;
}
function giveGift(target: Person) {
if (target instanceof Chef) {
target.giveGift(target.favoriteCookingUtensil); // We know target is a Chef, so we can access favoriteCookingUtensil
} else {
target.giveGift(BadGifts.coal);
}
}
function toUpperCaseString(input: string | number): string {
if (typeof input === 'string') {
return input.toUpperCase(); // We know input is a string, so we can call toUpperCase()
} else {
return input.toString();
}
}
Assert Signatures
Let’s look at a slightly different scenario. Suppose we are receiving a JSON response from a web request, and we want to validate that the JSON is of the expected response type. If not, we will throw an error. Our code might look something like this:
interface ServiceResponse {
numberOfCats: number;
internetFrenzyIndex: number;
}
async function getResponse() {
// Suppose httpClient.get(url) returns a promise of a parsed JSON response as type any.
const response = await httpClient.get(serviceUrl);
validateResponse(response);
console.log(`Panic Level ${response.internetFrenzyIndex} due to ${response.numberOfCats} cats!`);
// Properties are not type-checked because response is type any
}
function validateResponse(canidate: any): void {
if (
typeof candidate?.numberOfCats !=== 'number' ||
typeof candidate?.internetFrenzyIndex !== 'number
) {
throw new Error('Service Response Invalid')
}
}
Once again, we’re performing type-checking, but we are not telling the type system about it. Here, we aren’t returning a boolean, though, so Type Predicates will not help. Instead, we can use Assert Signatures:
function validateResponse(canidate: any): asserts candidate is ServiceResponse {
if (
typeof candidate?.numberOfCats !=== 'number' ||
typeof candidate?.internetFrenzyIndex !== 'number'
) {
throw new Error('Service Response Invalid')
}
}
Once again, a simple change of the function signature solves our issue. By using the asserts
keyword, the type system knows that by simply continuing execution passed a call to this function, it is guaranteed that the type assertion is true. Therefore, as long as we don’t exit a try
block, the rest of the function will assume that response
is of type ServiceResponse
.