Fun with JavaScript type coercion
Type coercion is the term used for automatic conversion between data types in JavaScript. If you're not careful, it can be a cause for subtle bugs in your code. This post is dedicated to one such example I recently encountered.
Let's start with the following data structure for describing a product price:
export interface PriceInfo {
basePrice: number;
discountedPrice?: {
price: number;
discountPercentage: number;
};
}
A regular price without discount only has the basePrice
field set:
const regular: PriceInfo = {
basePrice: 10,
};
A price with a discount applied also has the discountedPrice
field set:
const discount: PriceInfo = {
basePrice: 10,
discountedPrice: {
price: 9,
discountPercentage: 10,
},
};
Based on that knowledge, the following method can be used for getting the currently valid price:
export function getCurrentPrice(price: PriceInfo): number {
return price.discountedPrice && price.discountedPrice.price
? price.discountedPrice.price
: price.basePrice;
}
It works well for non-discounted and discounted prices:
test("regular price", () => {
const price = getCurrentPrice(regular);
expect(price).toEqual(10);
});
test("discounted price", () => {
const price = getCurrentPrice(discount);
expect(price).toEqual(9);
});
But what happens if the product is given away for free with a 100% discount?
const free: PriceInfo = {
basePrice: 10,
discountedPrice: {
price: 0,
discountPercentage: 100,
},
};
test("free price", () => {
const price = getCurrentPrice(free);
expect(price).toEqual(0);
});
Well, the above test fails:
Expected: 0
Received: 10
Why does it happen if price.discountedPrice.price
is set? Let's explore further with another test:
test("conditional operator", () => {
const condition = free.discountedPrice && free.discountedPrice.price;
expect(condition).toBeTruthy();
});
This one fails as well:
Received: 0
So, the value is set. It just isn't truthy as the author of the buggy method incorrectly assumed. Let's confirm that with another test:
test("0 is falsy", () => {
expect(0).toBeFalsy();
});
This one passes. 0
is a falsy value. Taking this into account, we need to make the condition in the conditional operator more specific:
test("conditional operator", () => {
const condition =
free.discountedPrice && free.discountedPrice.price !== undefined;
expect(condition).toBeTruthy();
});
This test passes. It's time to update the method accordingly:
export function getCurrentPrice(price: PriceInfo): number {
return price.discountedPrice && price.discountedPrice.price !== undefined
? price.discountedPrice.price
: price.basePrice;
}
With this change, all three initial tests pass, including the one that originally failed:
test("free price", () => {
const price = getCurrentPrice(free);
expect(price).toEqual(0);
});
Hence, the bug is fixed. Too bad that the TypeScript compiler couldn't warn us about it because the code was perfectly valid JavaScript.
Fortunately, there's at least a less error prone way to write the same code in modern TypeScript by using optional chaining and nullish coalescing features introduced in TypeScript 3.7:
export function getCurrentPrice(price: PriceInfo): number {
return price.discountedPrice?.price ?? price.basePrice;
}
The new code is brief, makes the intention clear and avoids type coercion altogether.
You can get the sample code from my GitHub repository and try it yourself. Check each commit to see the code in different states.
Type coercion in JavaScript can be convenient but also dangerous because not all conversions are intuitive. If you're unsure, don't be afraid to check the documentation or write an extra test.