Avoiding assertions in TypeScript

2023-12-03T15:37:02.169Z 9 minutes

Often, when working with Typescript, people tend to go for type assertions to suppress errors. Though that does suppress the immediate error, it is often the case that you're causing yourself a pain by not fixing the underlying issue, and exposing yourself to bugs at runtime. The main advantage of Typescript is the types we define, the more accurate they are to reality the less often you'll encounter type related bugs in production.The rest of this article is about type assertion, non null assertion, and how to avoid it.

Type assertions

In TypeScript, a type assertion is like telling the computer, "Trust me, I know what I'm doing with this data." It's a way to override the default behavior of TypeScript's static type checking. Imagine you have a variable, and TypeScript thinks it's one type, but you're sure it's another. You might use a type assertion to say, "No, TypeScript, it's actually this type."

And, well, as a programmer, it totally is possible that you do know more about the type of data a variable would hold than what the Typescript compiler knows, but in that case, it's better to redefine the type to better model your situation instead of using type assertions at places where you think you want your data to be interpreted as a certain type, or why not just use type guards.?

Type guards

Type guards are a way to narrow a type to a more specific type via a validation function. Say, you have a string that can be one of a few possible values and you want to narrow it down to a specific type that a function accepts:

type Url = `https://${string}`|`http://${string}`;

declare function crawlUrl(url: Url): string;

declare function isValidUrl(url: string): boolean;

function processInput(url: string) {
	if (!isValidUrl(url)) {
		return;
	}
	const result = crawlUrl(url as Url);
	/* process result */
}

Here, we see that the crawlUrl function accepts a Url, and in processInput you can obviously be certain at the point you're calling crawlUrl that url is a Url since you must've exit the function if it isn't, but unfortunately you still needed to assert it's type as Url. That's because even though you know that isValidUrl has validated url at that point, the typescript compiler doesn't. Though, we can see that typescript is often quite good at type narrowing when we have an early return.

function isValidUrl(url?: string) {
	if (!url) {
		return false;
	}
	if (!url.startsWith('http://') && url.startsWith('https://')) { // no TS errors
		return false;
	}
	return true;
}

In this example, we use url.startsWith , the startsWith method is available on a string, so at that point, the typescript compiler knows tat url can only be a string and not undefined (if it didn't know that, it would have shown our famous red squiggly line - try it out - since the method startsWith doesn't exist on undefined). This was possible due to our type narrowing above. We immediately return from the function if url is defined, and Typescript is smart enough to know that, url is not defined inside the if body, but it is definitely just string after that i.e. it narrows the type string|undefined to string once the possibility of undefined is gone.

Coming back to the previous example, we see that typescript has not inferred that url is not any string but is a Url, so we need to give it a hint that our function isValidUrl is actually checking whether it's argument is a Url, so if it's not a valid URL, it's whatever type it was before (no narrowing and function returns), and if it is a Url, then we move on to the rest of the function's body with the type of url narrowed to the more specific type Url.

function isValidUrl(url?: string): url is Url {
	/* <hidden> */
	if (/* <hidden> */) {
		return false;
	}
	return true;
}

It's as simple as setting is return type as url is Url. This basically means that if the function returns true then the argument url should be narrowed down to a Url, otherwise, it should stay the same as it is. And now, you can use this isvalidUrl function anywhere and you won't need to assert as Url . Your validation is setting the correct type!

Non null assertions

Has it ever happened to you? You saw a red squiggly line on your editor, and then you put an exclamation mark on it, and boom the squiggly line's gone! Easy, right?

Well, jokes on you, mate. You just lied. You lied to the compiler, you lied the other parts of your code, you lied to the API's consumer. Yes, dropping a non-null assertion gets rid of an annoying error, but you're not really fixing anything, you're just hiding something.

In fact, the more assertions you make the more places you're creating for bugs to hide.

Here's an example,

type Result = {
	success: boolean,
	error?: string,
	data?: string,
}

Now, you might be tempted to do something like this:

const response = (await fetch<Result>(/* make your API call */)).data;
if (response.success) {
	processMessage(response.data!);
} else {
	processError(response.error!);
}

Again, this is something that the programmer knows, because you probably built the API you're fetching from or the API author communicated clearly that if there's an error then the API returns success as false, and an error key, otherwise, success will be true with some data. This certainly seems okay to go with right? Well, it is, but assertions make it easy to make mistakes in more complex scenarios. It looks okay in this case since we're dealing with six lines of code, with larger code bases and a little complex control flows, assertions like this increase and the area of your code where your asserted value appears increases, meaning you can't 100% trust the typescript compiler at more places because you may have asserted the value of a variable at some other place. The way to deal with it is to make sure any variable with an asserted type does not live long enough to cause any issues. Make it's life time as low as possible. A better way to deal with this is of course, telling TypeScript the truth.

Discriminating unions

The secret in dealing with non-null assertions always lies in redefining your types to better model the reality of the API contract you have. To fix the problem above, we'll use discriminating unions. What this means is that instead of a type having keys optionally, we can make the type a union of "real" types. Here's a better type for the above:

type Result = {
	success: true,
	data: string,
} | {
	success: false,
	error: string,
}

This type makes it clear that the key data is present only when success is true and similarly, error is only present when success is false. Typescript can now infer the type in your branches and the same code above would compile without the type assertion.

The key success is discriminating your Result union. It's an incredible pattern and you should definitely use it if you use typescript, get rid of those pesky optional types.

Narrowing

I already talked about narrowing in the sections above, but well, that is usually the most obvious way to go about it. In fact, both of the above rely on narrowing itself so "Narrowing" is more a general thing than a separate technique to deal with You have a type that's a string or a number? Check which one it is, and it'll be narrowed for you! Often the less we assert, the cleaner your code is. Assertion is definitely a code smell. If you're tempted to ever use an assertion, just look at your code and think about how you can narrow your type to the type you want instead of asserting it.

What about errors though?

try {
	if (x === 0) {
		throw {"message": "We're not gonna divide by zero over here"}
	}
	console.log("dividing", 5/x);
} catch (e) {
	if (e && typeof e === 'object' && 'message' in e && e.message && typeof e.message === 'string') {
		console.log(e.message.toLowerCase());
	}
}

Um, well, yea, that's error handling for you. Unfortunately at this time, TypeScript's error handling story doesn't look very good... or does it? 🤔 The thing is, you will just never know what the error type can be. The nature of errors is that they're unpredictable. So, in a catch block, the type of error as unknown is the most correct type for it to have. But what about the type information we already specified? We throw the type {message: string} if x===0 , shouldn't typescript ideally infer that?

Yea, I guess, but again Typescript probably wants you to think that it can be literally any error possible. So, the best way to go about this is of course creating a type guard that takes an unknown and validates whether it's the error type you had thrown.

Why?

Eh, but why bother? I want to get rid of the error and move on. I have a lot on my plate right now, I just want to make this thing run! Well, here's a few reasons why we should get rid of assertions as early as possible:

END
Copy