Tech Blog

3 bugs that TypeScript could have prevented

An obstacle that slows down development or a time saver? We talk about some of our experiences.

Giulio Zausa
R&D Developer
5 minutes read
typescript, frontend, web, and javascript
This article is also available in Italiano 🇮🇹

Every JavaScript developer, both frontend and backend, 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 is just telling us that the variable we are reading does not point to an object but to undefined. Finding the cause of these errors is fairly simple if the application is small enough, but it becomes exponentially more complicated as the codebase grows.

Fortunately, a tool has been invented to solve this kind of problems, TypeScript. TypeScript is a JavaScript superset that allows you to add type annotations to your code, providing also development tools that allow:

  • 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, however, we won't talk about the language specifically, since it already has an excellent documentation and many detailed guides. To show the real value of this tool we'd like instead to focus on our experience, and all the times TypeScript really helped us to solve certain problems.

🧨 1. Access to null values

A first example comes directly from our blog, during the development of the internationalization.

The first time a user visits the website their 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} />;
}
Try it on Playground

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 che following error:

The error we get from the compiler
The error we get from the compiler

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

As we said in the beginning, this specific example comes from our blog, which was written in plain JavaScript. If we had used TypeScript from the beginning we would have noticed this bug earlier during development, not days after the blog went into production. TypeScript therefore 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, such as Java, where null dereference is referred as the "million dollar mistake".

⏱ 2. Typing numerical values: time units management

Another hard problem when 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.

In SMC, we built 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 display 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 (a strange word worth learning!) 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 errors 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.

A 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} />;
}
Try it on Playground

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

Type aliases are not directly clear from IntelliSense
Type aliases are not directly clear from IntelliSense

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 behavior 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 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} />;
}
Try it on Playground

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 identifiers 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

In SMC we love 3D graphics, especially on the Web. But when using many different libraries like Three.JS and react-three-fiber, not all of them handle the coordinates in the same way.

While some handles 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 other 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
// What? 😳 🤯

Fortunately, TypeScript was designed specifically to find this kind of errors, 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 becomes 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];
Try it on Playground

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);
Try it on Playground

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);
}
Try it on Playground

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 time needed for tests.

Some might argue that everything we showed could be solved with proper testing or more attention. In SMC, however, we 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).

In SMC we applied the use of 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.

written by
Giulio Zausa
R&D Developer
Researcher and Software Engineer for SMC, he works on topics such as 3D Interfaces, User Experience, Immersive Reality and Computer Vision. Consultant for projects that require modern Web technologies, such as React, WebAssembly or WebGL. Passionate about 3D Graphics and Programming Languages, he graduated in Computer Science at Ca Foscari University of Venice.

You might also like…