Intro
TypeScript is based on the structural type system. In a nutshell, the types of variables involved in some operations in your code should not be explicitly "identical". TypeScript considers the operation valid if types have a similar structure and follow the same shape.
I'm not going to provide a comprehensive article on type compatibility. The goal is to share with you a small research on a side-effect caused by this feature. Namely an issue with duck typing.
Compatible types example
First, let's look at the following piece of code
interface Pizza {
name: string,
}
interface Beer {
name: string;
isDark: boolean;
}
let pizza: Pizza = { name: 'Gangsta Paradise'};
let beer: Beer = { name: 'Pirate Rage', isDark: true};
pizza = beer; // valid operation because Pizza and Beer are compatible
In the last line, we assign the Beer
data to a variable having the Pizza
type. Beer
and Pizza
are separate, completely independent interfaces. But TypeScript considers them as compatible and allows us to put new data in the pizza
variable.
In a real app such an assignment might be an issue causing mistake or vice versa a smart trick based on good understanding of TypeScript features. For our research, the idea of this assignment is not important. We only care that it is valid for Typescript.
Duck Typing
Let's proceed with writing code for our app. Both Pizza
and Beer
get a new numerical property price
. Also, in specific cases, we want to make some discount on products. Let's say 10% discount on pizzas and 20% on beers.
Following SOLID principles, we want to put the discount calculation logic in a separate function. The function is supposed to deal with all types of products. Something like this
interface Pizza {
name: string,
price: number,
}
interface Beer {
name: string,
isDark: boolean,
price: number,
}
function getDiscountPrice(product: Pizza | Beer): number{
const isBeer: boolean = //???
if(isBeer){
// beer gets 20% off
return product.price * 0.8
} else{
// pizza gets 10% off
return product.price * 0.9
}
}
Pay attention to the isBeer
flag. We want to run different logic for pizzas and beers. But how to find out, which type of product the function is dealing with?
If Pizza
and Beer
would be classes we could apply the instanceof
operator. But unfortunately, they are interfaces and instanceof
would not work.
Duck typing to the rescue! According to the interfaces we defined, Beer
has the isDark
property, but Pizza
don't. So we can calculate isBeer
like
function getDiscountPrice(product: Pizza | Beer): number{
const isBeer = 'isDark' in product;
if(isBeer){
// beer gets 20% off
return product.price * 0.8
} else{
// pizza gets 10% off
return product.price * 0.9
}
}
Note
It would be better to wrapisBeer
into a type guard. But let's keep it as it is for simplicity
Issue with duck typing for compatible types
Let's put together the getDiscountPrice
function and the type compatibility case we studied in the beginning.
interface Pizza {
name: string,
price: number,
}
interface Beer {
name: string,
isDark: boolean,
price: number,
}
let pizza: Pizza = { name: 'Gangsta Paradise', price: 100 };
let beer: Beer = { name: 'Pirate Rage', isDark: true, price: 100 };
pizza = beer; // (1)
const price = getDiscountPrice(pizza); // (2)
console.log(price); // price is 90
function getDiscountPrice(product: Pizza | Beer): number{
const isBeer = 'isDark' in product;
if(isBeer){
// beer gets 10% off
return product.price * 0.9
} else{
// pizza gets 20% off
return product.price * 0.8
}
}
After the assignment in line (1)
the variable pizza
is still typed as Pizza
. Given the code of getDiscountPrice
we might expect that the calculated discount price will be 80
(since any Pizza
is supposed to get 20% off).
But actually, the calculated price will be 90
. We have 90
because the pizza data happened to have the isDark
property which is unexpected for our duck typing approach. You can run this example and check the result in TypeScript Sandbox
You can rightly say "Wait a minute, the code works as expected. You, as a developer, put some strange data in the pizza variable in the line (1)
. Don't blame TypeScript"
Well, I know, I agree with you. That's the developer's fault. But the TypeScript let me do it, no compilation error was thrown. Type compatibility system considers code like (1)
as valid.
It might be a problem in a real-world big app. Imagine that pieces of code (1)
, (2)
, and the function declaration for getDiscountPrice
sit in different modules. The data flow and modules interaction is complicated. But the TS compilation reveals no errors. Valid type compatibility might result in a tricky issue if the assignment on (1)
is made by mistake.
Conclusion
TypeScript is a great tool. Its type checking is able to detect mistakes in code making the codebase safer. But TypeScript is not able to reveal all the flaws. For me, one of the most severe drawbacks of Typescript is the fact that the border between "detectable" and "non-detectable" mistakes is sometimes blurred.
I mean the code above is absolutely valid from the TS perspective. And still, it has some unexpected, error-prone behavior.
Know the tools you use. I obtained deeper understanding of TypeScript when studying this case. I hope it will be helpful for you as well.
Top comments (3)
This is interesting indeed. Even according to my VSCode Tooltip, Pizza is indeed still typed as Pizza and not as Beer.
You could mitigate this problem by using classes:
Thanks for your point! However I don't agree that using classes can mitigate the problem.
In your example the TS error is not caused by classes. The thing is
Pizza
andBeer
are not compatible due toisFrozen
property. Once we make classes compatible the same issue with the discount price occurs. Here is TypeScript Sandbox with class-based example.Well observed. You still need to put some elbow greese into properly distinguishing the two from one another.
I took your example and extended it a bit using polymorphism as I missed to show it in my sloppy example above. Now even though the two classes are compatible, you will still receive the proper price for each product.
Sandbox