The cost of a lie — why honesty is the best policy when working with TypeScript

Andrzej Gatkowski
SwingDev Insights
Published in
12 min readJul 1, 2024

Prologue

Imagine receiving a brand new pull request to your codebase — ‘Library A minor update’. You review the changes, and everything looks good: unit tests pass, integration tests are successful, and manual testing reveals no issues. With confidence, you merge the PR into production. After a successful deployment, you take a well-deserved break, grab a cup of coffee, and catch up with your colleagues. It’s shaping up to be a great day.

But as you settle back at your desk, you’re greeted by an unexpected sight: 99 unread messages in your Slack inbox. What went wrong?

Suprised Lego figure
Photo by Nik on Unsplash

In this world of code, lies can be as deadly as they are in the real one. When we deceive the TypeScript compiler, we risk compromising our entire application’s integrity. Every as unknown as Xcomes with a price that might grow over time.

This article aims to shed light on the following aspects:

  • The mechanisms through which we introduce deception into our codebase
  • The underlying reasons that drive us to resort to such practices
  • Strategies to break free from the cycle of deceit and embrace a more transparent coding approach

Chapter 1: The Art of Deception

To kick it off, let’s start with the definition of “the lie”:

A lie is a statement made by one who does not believe it with the intention that someone else shall be led to believe it.

But what does lying have in common with programming 🤔? It turns out that very much.

Man with fedora hat in the darkness surrounded by the city lamps.
Photo by Craig Whitehead on Unsplash

So now that we understand the definition of a lie, let’s ask ourselves a question:

When do we lie to typescript— and does it even matter?

Well, let me pose a follow-up: Have you ever found yourself in a situation where you’re trying to accomplish something with your code, but TypeScript is being too strict or not providing the flexibility you need? Perhaps you’ve tried using type assertions or explicit casts to “trick” the compiler into accepting code that’s technically correct but doesn’t quite fit the expected mold.

TypeScript does an excellent job of inferring types for our code. For instance, it perfectly captures the type of pets in this snippet:

let pets = [new Dog(), new Cat(), new Bird()];
let pets: (Dog | Cat | Bird)[]

Unfortunately, real-life coding is rarely as straightforward!

TypeScript compiles your code down to JavaScript, which doesn’t have a type system. This means that it doesn’t have any knowledge about the types at runtime. (You can read more about this concept here: https://www.totaltypescript.com/typescript-types-dont-exist-at-runtime)

Unfortunately, more often than not, you may encounter in your d̶e̶v̶e̶l̶o̶p̶e̶r̶ detective life cases like the ones below:

Case 1.

const params = new URL(document.location).searchParams;
const name = params.get("name");

const hello = `Welcome ${name!}`;

Case 2.

import { ColorsEnum } from 'happy-lib/colors';
import { LocalColorsEnum } from '../enums/color';
...

const handleColorChange = async (colorToUpdate: LocalColorsEnum) => {
await callImaginaryApi('/colors', {
color: colorToUpdate as unknown as ColorsEnum
})
}

Case 3.

type UserType = 'admin' | 'superadmin' | 'user'
type SpecialUsers = Extract<UserType, 'admin' | 'superadmin'>

const allowList = ['admin', 'superadmin'] as const satisfies SpecialUsers[];

const doAdminStuff = (userType: SpecialUsers) => {
// magic
}

const checkIfAllowed = (userType: UserType) => {
if (allowList.includes(userType)) {
doAdminStuff(userType as SpecialUsers)
}
}

Now take a step back, look at these cases again, and try to spot “the lie”.

Woman outside of on old wooden house in the fields at night with a flashlight.
Photo by alexey turenkov on Unsplash

Did you find them? Yes, No? (Maybe? I don’t know). If yes, congrats 🎉 If not, don’t worry and keep reading through.

We will solve all of the cases together 💪.

Case 1

const params = new URL(document.location).searchParams;
const name = params.get("name");

const hello = `Welcome ${name!}`;

Here we have a classic frontend case of parsing search parameters from the URL. We try to retrieve the name parameter and then return a welcome message. Unfortunately, this code holds a lie.

const hello = `Welcome ${name!}`;

Why is this a problem you may ask? Because if the URL doesn’t have the name param, you will end up with hello where name is undefined. The code includes ! — non-null assertion operator, which tells TypeScript that everything is fine and dandy, and we’re sure that name will always be defined (which is not the case here 😠). Fortunately for us, the fix is simple:

const params = new URL(document.location).searchParams;
const name = params.get("name");

if(!name) {
// handle error case gracefully
}

const hello = `Welcome ${name}`;

Using TypeScript narrowing, with the help of a good old if statement, we can now be sure that whenever we use name outside the if block, the value will be there. It's very important to remember that even if we hide the potential error, it doesn't mean it will go away.

Case 2

import { ColorsEnum } from 'happy-lib/colors';
import { LocalColorsEnum } from '../enums/color';
...

const handleColorChange = async (colorToUpdate: LocalColorsEnum) => {
await callImaginaryApi('/colors', {
color: colorToUpdate as unknown as ColorsEnum
})
}

What we see here is a simple case of updating color using some imaginary API. With how the code currently looks, everything seems fine. We manually tested that it worked and added unit tests. Yes, we accept colorToUpdate as a different enum, but we know that the values are the same and won't ever change.

Unfortunately, the line above holds a lie and one that’s hard to catch.

Let’s focus on the line below:

...
color: colorToUpdate as unknown as ColorsEnum
...

What it does is tell TypeScript:

Don’t worry, I know better that these values hold the same value.

Let’s consider a situation where the happy-lib/colors package receives a major patch. For an undisclosed reason, the ColorsEnum changes its keys' casing. These enums no longer hold the same values. The TypeScript compiler won't warn you about this, and unit tests won't catch the issue. It's either up to manual or end-to-end tests to catch this. But it doesn't need to be this way.

Let's try to rewrite this code to tell the truth. For the sake of this exercise, let's assume that we can't change the colorToUpdate type, and we need to handle this inside the function. Comparing enums in TypeScript (or JavaScript) is hard. What we can do is write a translator (or use a dictionary):

function transformColorEnum(localColor: LocalColorsEnum): ColorsEnum | null {
switch (localColor.toString()) {
case LocalColorsEnum.Red:
return ColorsEnum.Red;
case LocalColorsEnum.Green:
return ColorsEnum.Green;
case LocalColorsEnum.Blue:
return ColorsEnum.Blue;
default:
return null
}
}

const handleColorChange = async (colorToUpdate: LocalColorsEnum) => {
const translatedColor = transformColorEnum(colorToUpdate)

if (!translatedColor) {
// handle your error case
}

await callImaginaryApi('/colors', {
color: colorToUpdate
})
};

What did we achieve here? You are now protected from a case where, due to some API change, you start sending the wrong values.

Well, but you don’t want to map every enum that exists, and this only covers three colors. 😅

You’re right. There are many ways to improve this code (even without any libraries), but this time, I wanted to show you how you can handle that using the Zod library, which will do the heavy type lifting for you:

import z from 'zod';

const handleColorChange = async (colorToUpdate: LocalColorsEnum) => {
const translatedColor = z.nativeEnum(ColorsEnum).safeParse(colorToUpdate);

if (!translatedColor.success) {
// handle your error case
}

await callImaginaryApi('/colors', {
color: colorToUpdate
})
}

Case 3

type UserType = 'admin' | 'superadmin' | 'user'
type SpecialUsers = Extract<UserType, 'admin' | 'superadmin'>

const allowList = ['admin', 'superadmin'] as const satisfies SpecialUsers[];

const doAdminStuff = (userType: SpecialUsers) => {
// magic
}

const checkIfAllowed = (userType: UserType) => {
if (allowList.includes(userType)) {
doAdminStuff(userType as SpecialUsers)
}
}

After solving cases one and two, you might be already pointing out that we cast here. And you’ll be right! Fortunately, this time the case is not as severe. Because the code checks if the user is included in the permissions array, we are safe. However, we have lost the type of information that we should have. Let’s retrieve it while keeping the logic almost the same.

type UserType = 'admin' | 'superadmin' | 'user';
type SpecialUsers = Extract<UserType, 'admin' | 'superadmin'>;

const allowList = ['admin', 'superadmin'] as const satisfies SpecialUsers[];

const doAdminStuff = (userType: SpecialUsers) => {
// magic
};

const isSpecialUser = (userType: UserType): userType is SpecialUsers => {
return allowList.includes(userType as SpecialUsers);
};

const checkIfAllowed = (userType: UserType) => {
if (isSpecialUser(userType)) {
doAdminStuff(userType);
}
};

Wait, you did use a cast here, how is this code any better?!

The reason why this casting is okay is that we are using it within the type guard function isSpecialUser. TypeScript understands that if the includes check passes, means userType must be of the SpecialUsers type. Therefore, the casting is safe and doesn't violate type safety. This way, the userType the variable will hold the SpecialUsers type inside the scope of the if block.

Happy jumping person
Photo by Andre Hunter on Unsplash

3/3 cases closed detective — great job.

Chapter 2: The anatomy of the lie.

We’ve identified the issues in our code. Now we need to understand what caused them to occur in the first place.

In the world of coding, we often find ourselves in situations where we intentionally manipulate types or data structures to achieve a specific goal — much like how people might bend the truth in real life when they’re uncertain about something or want to take shortcuts.

Unknown data

The most common case of us “lying” is when the data comes from an uncertain source. For example, let’s consider this snippet:

// Fetch user data from the API
const response = await getUsers();
const users = await response.json();

Can we really know that users will be a specific type? Most often, you’ll see something like:

const users: Users = await response.json() as Users;

And most of the time, it works. However, the truth is that this code is not safe. We have no way of knowing whether the endpoint we call will return data in the specified format. By using a type assertion (as Users), we're essentially lying to our code and telling it what we want to be true - but these "white lies" are often the root cause of future problems, disregarding error cases. What happens if getUsers changes its returned type? We might assure TypeScript that everything is fine and that we're certain it will return a specific type (e.g., Users), but in reality, our code is not prepared to handle unexpected types. This can lead to runtime errors that may break your production code.

Photo by Jametlene Reskp on Unsplash

Being lazy (which is a good thing 😎)

There are also lies that may seem insignificant but can ultimately be challenging to debug.

Let’s consider an example where you have a massive mock object in your unit tests. You only care about one specific property, yet you want the benefits of autocompletion and type safety. In this case, you might end up with code like this:

const veryBigObjectMock = {
keyIWantToMock: 'myMock'
} as unknown as BigObjectType;

The compiler is happy, your tests run as intended… for now. However, one day your test fails, and despite knowing that the data structure has changed, TypeScript’s type checker (tsc) reports no issues. You might wonder if you’ve simply misunderstood the logic.

After wasting hours trying to figure out what went wrong, you finally remember a forgotten mock object with an outdated key name. Time lost.

A small tip: you can achieve this level of mocking while maintaining autocompletion by using the convention shown below. This approach allows you to focus on mocks specific to your case without having to mock everything.

const veryBigObjectMock: BigObjectType = {
...{} as BigObjectType,
keyIWantToMock: 'myMock'
};

Using data that isn’t ready.

Using data that isn’t ready can be a common pitfall. We may be tempted to access a property or method without checking if the underlying data can be safely used.

Let’s consider an example. Suppose we have a TodoList component that displays our todo items, which are fetched asynchronously, and then returned by the useSelector hook from Redux (this could be any other state management library):

import * as React from 'react';
import { useSelector } from 'react-redux';

type Todo = { id: string; name: string };

const TodoList = () => {
const todos: Todo[] | null = useSelector((state) => state.todos);

return (
<ul>
{todos?.map(todo => (
<li key={todo.id}>
{todo.name}
</li>
))}
</ul>
);
};
export default TodoList;

Whenever we use this component, it renders fine. However, if we’re perceptive (or on a slow connection), we may observe flickering — but only for less than a second, so it’s acceptable, right? What if the response takes several seconds to arrive, or do we want to use this component to show real-time updates for to-do items in our application? Using this naive approach would result in a subpar user experience. TypeScript told us that todos might be empty - yet we chose to ignore its warning.

Chapter 3: The power of the truth.

Photo by Oksana Manych on Unsplash

So far, we’ve focused on how lies look in the code and what reasons lead to their appearance in our codebase. But what can we do to tell the truth? How can we stop the lies?

Fortunately for us, while not always easy, it will improve our lives a lot.

Trust typescript.

It wants the best for you. It warns you and prevents you from making mistakes. There is a good reason for the errors and warnings you see in your code.

Add strict eslint, and tsconfig rules.

Automate what you can, and use a strict @typescript-eslint config. It not only shows errors but can also prevent you from making future mistakes. Set up your tsconfig using best practices and recommendations (e.g., https://www.totaltypescript.com/tsconfig-cheat-sheet) so it will keep you on the right track. The less you need to check manually, the better.

Let typescript do its job.

With the aid of flow control statements such as if-else conditions and switch statements, TypeScript can refine its understanding of variable types by isolating specific sections of code. Let's say you have:

const optionalValue: string | null = getOptionalValue();

if(!optionalValue) {
return;
} else {
// rest of the code
}

optionalValue will always have value in the else body, no need for any manual typechecks.

Move with certainty.

If a value can potentially be undefinedor null, it’s likely to occur, so anticipate and handle such cases gracefully. Narrow down the possibilities through input validation, defensive programming, and type checking. Employ strategies like providing default values, implementing error handling, and writing defensive code that checks for and handles these cases before operations.

Validate at runtime.

While preventive measures like input validation and type checking are essential, it’s also crucial to validate data at runtime, especially when dealing with unknown or untrusted objects. One powerful tool for achieving this is zod, a TypeScript-first schema validation library that lets you define strict schemas describing the expected shape and types of your data — think blueprints for your data structures! With Zod, you can create robust schemas that serve as sentinels, guarding against invalid or unexpected data at runtime. For instance, if you’re receiving data from an external API or user input, you can define a Zod schema representing the expected structure of that data and then use it to validate incoming data against — ensuring it adheres to specified types, shapes, and constraints. If the data doesn’t meet these expectations, Zod throws an informative error, allowing you to handle the issue gracefully. You could also write your parser that would implement techniques like type-guards, or assertion functions.

Chapter 4: Finale.

Type assertions in TypeScript allow you to lie to the compiler about the type of a value. While they have limited valid use cases, they are frequently abused and can introduce insidious type errors that defeat the purpose of static type checking.

Keep in mind that often, they act as necessary evil in your code, and you really can’t do much about them.

In a perfect world, all the heavy typing could be done in the libraries we use, and we would only write code as close to pure JavaScript as possible. Unfortunately, this would be too good to be true, and you need to be cautious about how you structure your types.

TypeScript is designed to help developers catch errors and improve code quality, but it’s not a silver bullet. You need to remember that it’s still the same old JavaScript underneath. While TypeScript provides powerful tools for static type checking, it cannot eliminate all potential runtime errors.

Thank you for going through this entire article with me, and remember:

Don’t Lie.

See you later card
Photo by Junseong Lee on Unsplash

--

--