Skip to main content

Using TypeScript Types

· 8 min read
Software Engineer

I've been using TypeScript more and more lately. And I don't like it. I've found that I spend more time fixing type mismatch errors than I save by having a strongly typed variant of JavaScript. However, I am commonly in the minority opinion among my teams. In this article, I'll explore why that is, how to better leverage TypeScript's typing system rather than treating like annotations on top of Javascript, and I'll go through some hands-on examples based on a few real-world cases we recently encountered.

My Dislike of TypeScript

While I haven't been a fan of TypeScript, I accept that I am often in the minority opinion. I suspect that is in part due to a lack of fully understanding the language capabilities and in part having become to set in my ways with the languages and tools I like.

Despite not preferring TypeScript, we've been using it increasingly on my teams for a few reasons. First, it provides clear annotations of types, particularly for object interfaces. Second, you can declare anything as type any, at which point you have effectively turned off the type system and you're back to writing Javascript. Simply put, I felt it doesn't do any harm and some people like it, so we should use it.

Manufacturing Rectangles

In a recent project, our team was building a web application that generates barcodes and text files needed by manufacturing equipment. Let's explore a simplified version of the problem we were solving.

Data Structure

The company manufactures rectangles. Each rectangle has a width and a height. And some have an offset that is used to adjust the dimensions slightly. Let's begin by considering or primary data structure.

interface Rectangle {
width: number;
length: number;
useOffset?: boolean;
offset?: number;
}

We'll use the useOffset boolean to determine whether or not to apply the offset. And then the offset field to determine how much offset to apply.

Real-World Complexities

You may be looking at this and be wondering, why not use a single offset field and set it to 0 rather than also have the useOffset boolean. In our real-world case, this field comes from a legacy database and is used elsewhere in the system. This field is the source of truth for whether or not to apply the offset.

The offset on the other hand is a derived and computed field. Meaning it is possible that this field could be missing or incorrect as our data transformation pipeline behavior is changed. Thus we use the useOffset field to drive behavior for better reliability in a rapidly changing system.

Data

We have a few different sizes of rectangles. For simplicity, let's say the company makes 10x20 rectangles and 5x10 rectangles with or with offsets of 0.5. We can represent that using the following array:

const rectangles: Rectangle[] = [{
width: 10.0,
length: 20.0
}, {
width: 5.0,
length: 10.0
}, {
width: 10.0,
length: 20.0,
useOffset: true,
offset: 0.5,
}, {
width: 5.0,
length: 10.0,
useOffset: true,
offset: 0.5,
}]

The Code

The core computation we're performing here is a calculation of the final dimensions of the rectangle which will then be passed along to the manufacturing equipment. The notable logic here is that when useOffset is true, we add the offset to both the width and the length.

function computeDimensions(item: Rectangle) {
let width = item.width;
let length = item.length;

if (item.useOffset) {
width += Number(item.offset);
length += Number(item.offset);
}

const formattedWidth: string = formatLength(width);
const formattedLength: string = formatLength(length);
return { width: formattedWidth, length: formattedLength };
}

We then format the length as a string and return an object containing the string representation of the dimensions.

Our formatLength function is used to compute the final dimensions of the rectangle rounded down to the nearest eighth. The code for that function looks like this:

function formatLength(length: number): string {
const root = Math.floor(length);

const decimalPart = length - Math.floor(length);
const numberofEighths = Math.floor(decimalPart / (1/8));
const decimalEigths = numberofEighths * (1/8);

return `${root + decimalEigths}`;
}

When we run out code, we get what we'd expect. Our main function, simply loops over each rectangle and computes/prints the dimensions.

function main() {
rectangles.forEach((item) => {
const dim = computeDimensions(item);
console.log(`Final Dimensions: ${dim.width} x ${dim.length}`);
});
}

Output:

Final Dimensions: 10 x 20
Final Dimensions: 5 x 10
Final Dimensions: 10.5 x 20.5
Final Dimensions: 5.5 x 10.5

Typing Problems

Unexpected Values

Problems arose however when there was a mismatch between the legacy useOffset field and the computed offset value.

The offset is based on the material of the rectangle. If the material from the legacy system didn't exist in the new system, the computation would fail and leave the offset value as undefined.

Let's walk through how the code executed in this case:

computeDimensions({
width: 10.0,
length: 20.0,
useOffset: true,
offset: 0.5,
});

When we run this, we get the following output:

Final Dimensions: NaN x NaN

How it happened

The problem is ultimately occuring on lines 6 and 7 of the below snippet:

function computeDimensions(item: Rectangle) {
let width = item.width;
let length = item.length;

if (item.useOffset) {
width += Number(item.offset);
length += Number(item.offset);
}

const formattedWidth: string = formatLength(width);
const formattedLength: string = formatLength(length);
return { width: formattedWidth, length: formattedLength };
}

When we lookup item.offset, it is undefined because it was never computed and stored. When we call Number(undefined), we get NaN (Not-a-Number). This is described in the ECMAScript specification here.

We are then effectively performing the following operation: width = width + NaN. We can also find in the ECMA specification that any number plus NaN is also NaN. Thus, we've just made the width and length, NaN.

Finally, when we call formatLength(NaN), we get the string "NaN". This results in barcodes and text files containing the string "NaN" rather than the expected numerical dimensions.

This ultimately leads us to the question, why didn't typescript help here? We declared width and length (lines 2 and 3) and set them equal to number values. Why didn't TypeScript catch this?

Why TypeScript Didn't Help

The answer in this case is that the type of NaN is actually number. This means that when we perform the width = width + NaN expression, our new width has a value of NaN, which is of type number. Thus, TypeScript doesn't catch this as an issue.

Unfortunately, TypeScript doesn't have a way to address this. Though a solution has been proposed, it doesn't seem to be something on the TypeScript roadmap.

Solutions

Make the offset required but nullable

This solves the problem because Number(null) returns 0, where Number(undefined) return NaN. This means using null will make the width add an offset of zero and give the expected result.

Use a default value

In this case we use a default offset value if the offset is falsy. The new code looks like this:

function computeDimensions(
item: Rectangle
): { width: string, length: string } {
let width: number = item.width;
let length: number = item.length;
const offset = item.offset || 0.0;

if (item.useOffset) {
width += Number(offset);
length += Number(offset);
}

const formattedWidth = formatLength(width);
const formattedLength = formatLength(length);
return { width: formattedWidth, length: formattedLength };
}

Explicit NaN checks

In this case we use a default offset value if the offset is falsy. The new code looks like this:

function computeDimensions(
item: Rectangle
): { width: string, length: string } {
let width: number = item.width;
let length: number = item.length;
let offset: number = Number(item.offset);

if (isNaN(offset)) {
offset = 0.0;
}

if (item.useOffset) {
width += Number(offset);
length += Number(offset);
}

const formattedWidth = formatLength(width);
const formattedLength = formatLength(length);
return { width: formattedWidth, length: formattedLength };
}

Solution Selection

Our solution ultimately used the default value approach. We did this for a few reasons.

  1. First, it kept the solution isolated to the function that was exhibiting buggy behavior, reducing the likelihood of unexpected side effects from this fix.
  2. Second, it works for any falsy value of item.offset (e.g. null, 0, "", false). Which will ultimately be more robust in the long term.

The explicit NaN check is also a good solution, but we felt it was more verbose. Our coding style favored the default value approach, but for our specific case, either approach would have worked.

Concluding Thoughts

When I set out to write this article, I expected to learn better ways to use TypeScript. Instead, I was surprised to learn that I had stumbled upon a unique case where TypeScript's type system cannot catch the issue we encountered and knowledge of Javascript's typing behavior and explicit type handling was needed.

I'd like to keep exploring TypeScript and see if I find that this was an uncommon case or if there are more cases where TypeScript doesn't catch issues that I'd hope it would.