DEV Community

Mikhail Istomin
Mikhail Istomin

Posted on

TypeScript: type compatibility vs duck typing

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

Note
It would be better to wrap isBeer 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
tqbit profile image
tq-bit

This is interesting indeed. Even according to my VSCode Tooltip, Pizza is indeed still typed as Pizza and not as Beer.

Image description

You could mitigate this problem by using classes:

Image description

Collapse
 
mistomin profile image
Mikhail Istomin

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 and Beer are not compatible due to isFrozen property. Once we make classes compatible the same issue with the discount price occurs. Here is TypeScript Sandbox with class-based example.

Collapse
 
tqbit profile image
tq-bit

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