TypeScript: How Type Guards Can Help You Get Rid of 'as'
Fatine Benhsain9 min read
In TypeScript (TS), the “as” keyword is widely used when manipulating types. However, you should employ it with caution, as it does not provide any guarantee on the real types of your objects and could generate unexpected bugs. In this article, I will go into more detail about “as” and explain how to avoid it by using type guards, which represent a safer alternative.
Before I started working at Theodo Lyon, I had only used Vanilla JavaScript and HTML in my previous web development experiences. So when I discovered TypeScript (TS) earlier this year, it seemed like a miraculous solution for writing safer code! It was when I joined a team working on a web app using React jointly with TypeScript. React is a free and open-source “JavaScript library for building user interfaces” developed by Facebook, while Typescript is a programming language developed as a superset of JavaScript that adds optional static typing to it. In other words, defined variables are assigned a specific and permanent type at creation, hence securing their future use.
A few months ago, we started noticing many obscure bug occurrences with no obvious explanation. Also, the investigation was not satisfactory due to the lack of error logs: usually, the user would simply land on a blank page instead of the desired one.
One of the most common causes was that some objects suddenly became undefined
while that was not an option for TS since they had different types. For example, we encountered error messages such as :
However, most of these occurrences would not even leave any error message and could lie undetected for a long time. Hence the investigation was even more painful!
Through our analysis, we found out that most of these errors were linked to the usage of the as
keyword in TS.
For example, suppose we have an API used to retrieve books data, and a type Book
defined as:
export interface Book {
id: number;
author: string;
publisher?: string;
}
When I fetch the book of id 5:
const book = fetch("baseUrl/get-book?id=5");
the received object will be of type unknown
. But since I know the expected return type, I can force TS to use the type Book
by using as
:
const book = fetch("baseUrl/get-book?id=5") as Book;
But how does “as” work exactly?
This keyword is a Type Assertion used to inform the compiler to consider an object as a different type from the one inferred. The as
assertion does not perform any transformation on the data stored in the variable and is removed at compilation. TS even warns us about this in the official documentation:
Furthermore, it only checks that the assertion is feasible. For example, you can not use it to turn a string into a number: “TypeScript only allows type assertions which convert to a more specific or a less specific version of a type.” (“TypeScript Handbook”)
By digging deeper in the source code, the as
does not perform any checks on the object but only serves as a static typing tool:
Screenshot extracted from source code typescript.js
Usually, we used to employ the as
keyword in order to handle data coming from external sources. Examples of such sources could be APIs or imported excel files. Since the content was not known in advance, the type could not be inferred, so we had to use as
to let the compiler understand the right type.
However, using this keyword in this case creates a false confidence feeling. The purpose of as
is neither to protect nor to ensure typing, its main use being informing us about the hypothetical type of the object. For instance, in our previous example, I could fetch a book without knowing that the API has changed its definition of the type Book
. Indeed, we could imagine that the type of the id
attribute has changed from number
to string
, when we write:
const book = fetch("baseUrl/get-book?id=5") as Book;
const nextBookId = book.id + 1;
TS thinks that book.id
is still a number, which could result in errors.
So, once we figured out the purpose of as
, we started by implementing what would later turn out to be a quick fix. We kept the as
keyword, but added checks right after the problematic occurrences to make sure that the object’s attributes were not undefined
:
const book = fetch("baseUrl/get-book?id=5") as Book;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (book.author !== undefined) {
const authorLastName = book.author.split(" ")[1];
}
This could prevent the problems cited above. However, it was not sustainable in the long-term, because we had to remember to perform the check every time we used an object whose type had been defined with as
. Moreover, an “ignore” annotation was needed since the type was clear for our code linter eslint
, which considered that the type defined by as
was correct.
Yet, some of the bugs persisted, until one day, we discovered type guards through a workshop. Little did we know, at the time, that they could solve most of our problems!
But first, what are type guards?
A type guard is a function that allows you to narrow the type of an object to a more specific one by performing certain checks. Hence, after calling the function, TypeScript understands the new type of the object.
typeof and instanceof are two built-in examples of such type guards:
-
typeof only returns one of the following: “string”, “number”, “bigint”, “boolean”, “symbol”, “undefined”, “object”, “function”:
typeof 90 === ‘number’ typeof "abc" === ‘string’
-
instanceof checks whether an object is of a specific type, for example:
new Date() instanceof Date === true
However, instanceof does not work with TypeScript interfaces, which are what we usually use.
Since neither operator could solve our issues, we decided to build our own brand new type guards. Let us consider for instance an element
object whose type is unknown
being actually of type Book
. A working function allowing us to verify this assertion would be:
const isBook = (element: unknown): element is Book =>
Object.prototype.hasOwnProperty.call(element, "author") &&
Object.prototype.hasOwnProperty.call(element, "id");
A custom type guard takes an element in input and returns a type predicate, which in our example would be element is Book
. Now, whenever we handle an element that could be a Book
, we can use this function to check its type. Also, once the variable verifies the checks, TypeScript interprets its type as a Book
allowing us to access its attributes:
const gift: unknown = getGift();
if (isBook(gift)) {
/*
* In this if-block the TypeScript compiler actually
* resolved the unknown type to the type Book
*/
read(gift);
// read needs an argument of type Book
return gift.author;
}
The other advantage of type guards is that we can unit test them, preventing problems:
Here is an example with jest (the JS testing framework we use):
it("should return true if the element is a book", () =>{
const element1 = { author: "alpha"};
expect( isBook(element1)).toBe(false);
const element2 = {"id": 2000};
expect( isBook(element2)).toBe(false);
const element3 = { author: "alpha"; "id": 2000};
expect( isBook(element3)).toBe(true);
})
Still, as we can see above, our type guard is incomplete. An object such as { author: 1904; "id": new Date()}
would be wrongly considered to be of type Book
.
To avoid this mistake, the function should also check the attributes’ types. Hence, We can modify it as follows:
export const isBook = (element: unknown): element is Book =>
/*
* We can not use "in" because element
* has type "unknown"
*/
Object.prototype.hasOwnProperty.call(element, "author") &&
typeof element.author === "string" &&
Object.prototype.hasOwnProperty.call(element, "id") &&
typeof element.id === "number";
However, this fix will not be effective since the element type would still be unknown, preventing us from accessing its attributes directly:
No worries, the deliverance could result from 2 options:
-
either we split the function into 2 type guards, verifying the attributes’ existence first, then their types each time we handle types
-
or, writing up a generic function
hasAttributes
that could check any attribute’s actual presence, permitting us to scale up the types’ checking. An example of such usage could be:export const isBook = (element: unknown): element is Book => hasAttributes(element, ["author", "id"]) && typeof element.author === "string" && typeof element.id === "number";
Finally, we can access the attributes because we have checked their existence first.
Wait a second, how can we define hasAttributes
?
The function takes an object of unknown type and returns a type predicate depending on the attributes given as an input to the function. In other terms, it needs to be a “generic”, defined for instance in the following way:
export const hasAttributes = <T extends string>(
element: unknown,
attributes: T[],
): element is Record<T, unknown> => {
if (element === undefined || element === null) {
return false;
}
return attributes.every((attribute) =>
Object.prototype.hasOwnProperty.call(element, attribute),
);
};
The type Record<T, unknown>
means that the element
object in input does possess the wanted attributes but their values are of type unknown
.
This is all great, but can we automate it a bit?
It is true that many type guards libraries already exist, like typecheck.macro, but we have not had much luck using them. The main pain point with this library for example was that it makes our unit tests fail, we could not make it work with jest for the present time. Also, some other libraries require us to change all our type declarations to fit theirs in order to use them.
However, since we now know how important type guards are and how much they can ease the developer experience, their automation could be feasible but is at the time being still a challenge.
Conclusion
As we have seen, we have nearly stopped using the as
assertion to state objects’ types, since it is very error-prone and rarely necessary. In contrast, type guards have proven to be amazing tools, making our code cleaner, more stable, and more robust. In most cases, they can replace all of your as
usages. As a matter of fact, my colleagues and I were not able to come up with any good occurrence which could not be replaced with a type guard, however hard we tried. Indeed, anytime you are faced with a type incoherence (a wrongly typed library, external data etc.) you can resolve it using a type guard.
If you still are not convinced about their enormous potential, try them for yourself. Write your first type guard and get ready to see your developer experience improved!