3 TypeScript Bugs you should consider before using it

Some aspects of the most famous Javascript superset you should be aware of

Denis Cangemi
Bumpware
8 min readJan 1, 2023

--

Every JavaScript developer will surely have experienced the problems of writing code without a type checker.

How many times have you seen errors like this?

Uncaught TypeError: Cannot read property 'value' of undefined

This cryptic message says that the variable we are reading does not point to an object but to undefined.
Finding the cause, in a small application, is fairly simple, but it becomes more complicated as the codebase grows.

Fortunately, a tool has been invented to solve this kind of problem, TypeScript.

TypeScript is a JavaScript superset that allows you to add type annotations to your code, providing also development tools:

Type correctness checking, such as:

  • Access to null values
  • Missing fields on objects
  • Not implemented edge cases

Real-time type-based code Autocompletion:

  • Strong integration with IDEs like Visual Studio Code
  • Allows to have always updated and correct documentation, since it’s based directly on the types in the code

In this article, I want to show the real value of this tool based on my experience, and all the times TypeScript really helped me.

🧨 1. Access to null values

Let’s take an example:

A user visits a website and its preferred language is saved in LocalStorage.

When the user visits the root of the website again, they’re redirected to the correct language path (/en for example), using a table that contains the path for every language.

This wrong implementation works fine until someone removes a language from the website or corrupts the saved preference.

const languages = {
it: { baseUrl: "/it" },
en: { baseUrl: "/en" },
};
const defaultLanguageKey = "it";

export default function IndexPage() {
// Saved preferred language from LocalStorage
const preferredLanguageKey = window.localStorage.getItem("lang");

const languageKey = preferredLanguageKey || defaultLanguageKey;
const path = languages[languageKey].baseUrl;

return <Redirect to={path} />;
}

Reading the language’s baseUrl field, if languageKey is not present in languages, we will read an undefined field.
This causes a TypeError: languages[languageKey] is undefined exception, that will crash React and the entire application.

If we analyze the code with TypeScript, keeping it as-is without adding any type annotation, we will get the following error:

Typescript was able to spot the mistake without any additional type annotation, saving us from a probable crash (try it yourself on the Playground).

If we had used TypeScript from the beginning, we would have noticed this bug earlier during development.

TypeScript proves itself very useful to avoid this whole class of errors, greatly reducing the time necessary for testing.

Being able to verify accesses to null fields makes TypeScript even more powerful and secure than other compilers.

⏱ 2. Typing numerical values: time units management

One of the biggest problems in developing software is time management.

Dealing with durations, timestamps and timezones requires a lot of attention since it can easily lead to off-by-one errors or wrong units.

Let’s take for example a web application to manage employees' timetables: we receive an employee’s information from a service, including their working hours, saved as minutes in JSON format.

const employee = {   
name: "John Doe", //string
shiftHours: {
monday: 60, //number
tuesday: 120,
wednesday: 240,
thursday: 180,
friday: 60,
saturday: 120,
sunday: 0,
},
};

In this case, TypeScript interprets shiftHours values type as number.
This may sound harmless, but it doesn't allow us to verify that they are represented as minutes, not as hours.

Unless you remark it in the documentation, another developer could easily make a mistake and swap the unit of measure, interpreting that number as hours.

function HourDisplay({ hours }: { hours: number }) {   
return <>{hours} hours</>
}

function EmployeeDisplay({ employee }: { employee: Employee }) {
return (
<>
<h2>Name: {employee.name}</h2>
{Object.entries(employee.shiftHours).map(([day, time]) => (
<p key={day}>
{/* We are showing minutes as hours… whoops! */}
{day}: <HourDisplay hours={time} />
</p> )
)}
</>
);
}

Using this React component that displays the information for an employee we won’t have errors or exceptions in runtime, and in fact, TypeScript considers it as correct.

The problem with this implementation is that we are subsuming everything as number.

The number type is completely generic, and therefore the compiler does not enforce the correctness of the units of measurement.
However, thanks to TypeScript, we can avoid this type of logical error by using a pattern called Nominal Typing.

This technique involves adding more strict annotations to basic types, such as numbers or strings, allowing to type check their content as well.

The first solution would be to use a type alias.
We soon realize, however, that it does not work as we would expect:

type Hours = number; 
type Minutes = number;

function HourDisplay({ hours }: { hours: Hours }) {
return <>{hours} hours</>;
}

function App() {
const test: Minutes = 60; // We don't get an error! 😕
return <HourDisplay hours={test} />;
}

This way, a developer who uses the HourDisplay component won't even notice in the auto-completion that the expected type is in hours (Hours), since the aliases are removed for clarity.

The code above compiles without errors, since TypeScript uses Structural Typing, not Nominal Typing.
This means that checking whether two types are compatible is done with their content, not their name.
This behaviour may seem strange if a developer comes from languages like Java or C#, in fact, it comes from functional languages like Haskell or OCaml.

To solve this problem and check the types in a nominal way, there are many possible approaches, both runtime based and compile-time only. However, all these patterns have in common, adding a tag field to an object that acts as a discriminant.

The standard solution, used by TypeScript compiler developers themselves, is the following, based on a void tag present only at compile-time:

interface Hours {   
_hoursBrand: void,
value: number;
}

interface Minutes {
_minutesBrand: void;
value: number;
}

function HourDisplay({ hours }: { hours: Hours }) {
return <>{hours.value} hours</>
}

function App() {
// Type assertion
const test = { value: 60 } as Minutes;
// Next line yields an error, as expected
return <HourDisplay hours={test} />;
}

This pattern is very useful not only when managing time units, but also in many other cases.
For example, we have used this technique on some projects to manage multiple identifier types, adding nominal types to strings. This allowed us to save a lot of testing time given the complexity of the domain.

For more information about Nominal Typing, I recommend reading this article and this library.

3. 📌 Constant size arrays: XYZ coordinates

If you love 3D graphics, especially on the Web, when using many different libraries like Three.JS, not all of them handle the coordinates in the same way.

While some handle them as objects with x, y and z, in other libraries we find the coordinates represented as a three-element array [x, y, z]:

// With Objects (THREE.Vector3) 
const p1 = { x: 2, y: 3, z: 4 };

// With Arrays
const p2 = [2, 3, 4];

When some functions expect coordinates as arrays and others as objects, it’s very easy to make mistakes, producing errors in the console that are not immediately understood:

// We got our points 
const p1 = new THREE.Vector2(25, 25);
const p2 = new THREE.Vector2(0, 15);

// This functions expects objects
const curve = new THREE.SplineCurve([p1, p2]);

// And this expects arrays
var myShape = new THREE.Shape();
myShape.moveTo(p1.x, p1.y);
myShape.lineTo(p2.x, p2.y);
myShape.getLength(); // 26.925824035672584

// if we pass objects instead...
const myShape2 = new THREE.Shape();
myShape2.moveTo(p1);
myShape2.lineTo(p2);
myShape2.getLength(); // NaN

Fortunately, TypeScript was designed specifically to find this kind of error, and type-checking the code immediately finds the error.
This is because Three.JS has typings that allow the compiler to verify the correctness of the function parameters.

However, things become slightly more complicated when we want to use arrays to represent coordinates.
While TypeScript can easily verify the presence of the x, y and z fields of an object, when we access an array with the notation arr [N] we may read values that are out of bounds.

In fact, TypeScript does not perform bounds checking on arrays, since it depends on run-time information.
All arrays represented as T[] have no compile-time bounds checking, and literal arrays are interpreted this way by default.

We can have better checking using Type Narrowing:

const pointA = [12, 3, 8]; // number[]  

// No bounds checking 😕
console.log(pointA[4]); // undefined

// We force (narrowing) our type to be a three number array
const pointB: [number, number, number] = [12, 3, 8];

// We get an error as we want 🥳
console.log(pointB[4]);

// To avoid repetitions, we can also use an alias
type Point = [number, number, number];
const pointC: Point = [12, 3, 8];

As we forced our type to be a three-number array, we can also do the same with different types:

// We can also use different types 
const tuple: [number, number, string] = [12, 3, "test"];

// tuple[0] is a number
console.log(tuple[0] - 5);

// tuple[2] is a string, and so we get an error
console.log(tuple[2] - 5);

However, if our data is not a constant, for example, if it comes from a service or from a file, we should not cast it using as, as it would be unsafe: we don't know what data we are going to get.

To get even more security we can combine a runtime control with a Type Guard:

type Point = [number, number, number];  

// We assert that this function is expected to return true if the parameter is a valid Point
function isValidPoint(arr: any[]): arr is Point {
return arr.length === 3 && arr.every((k) => !isNaN(k));
}

const point = [2, "a"];
if (isValidPoint(point)) {
// Out point is valid, we can access it safely
console.log(point[2] - 3);
} else {
// The point is not valid, and accessing it yields an error 🤯
console.log(point[2] - 3);
}

Conclusion

We have seen how TypeScript can become a valid ally if you want to write safe and correct code, minimizing errors on edge cases and the time needed for tests.

Some might argue that everything I showed could be solved with proper testing or more attention, however, I think that the value that TypeScript brings is really this: it decreases the cognitive load that developers have to use when writing code at the small cost of a little more verbosity.

The types act as a reference and help thanks to the IDE, so we no longer have to look for the parameters of a function in the documentation (which is often not even updated).

I used TypeScript to every complex and mission-critical projects, obtaining a decrease in the time required for bugfixes, as much less bugs are found in the testing phase.

Thanks for reading!

--

--

Denis Cangemi
Bumpware

IT Project Manager | Writing about Coding and Project Management | ISIPM-Base® / PSM™ I / SFC™