Using TypeScript Types
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.
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.
- First, it kept the solution isolated to the function that was exhibiting buggy behavior, reducing the likelihood of unexpected side effects from this fix.
- 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.