Photo by Ravi Singh on Unsplash

Understand duck/structural typing in TypeScript and it’s downsides

Kacperwitas
5 min readJan 18, 2021

--

In this article im going to talk about what in general is a duck typing (also called structural typing). I will discuss this term and show you some code examples in TypeScript.

At the very end I will show you pros and cons of duck typing.

Note: In this article I am going to use Structural typing and Duck typing alternately as these terms are equal.

Lets begin!

Duck typing

Duck typing is a form of logical inference (abductive reasoning).
In short: it is used to check the type compatibility between two types based on the shape rather than the name.

The expression of duck typing is:

If it walks like a duck and quacks like a duck, it must be a duck.

What does it mean?

Imagine a situation: we have a duck which makes a quacking sound. Then somehow in a laboratory we create an apple, this apple can quack as well.
In the terms of duck typing our apple is a Duck!

Structural typing in TypeScript

Before explaining further let me show you code for previous example.

First of all lets create an interface for Duck, I am going to make it super simple so I will only create a method quack.

interface Duck {
quack: () => void;
};

Now create an interface for our apple, I decided to call it “WeirdApple”:

interface WeirdApple {
quack: () => void;
};

As you can see, we have 2 different interfaces: Duck and WeirdApple, but both of them share same method: quack.

Lets create a function “makeDuckQuack” which takes an object of type Duck and runs quack() method on that object.

const makeDuckQuack = (duck: Duck) => {
duck.quack();
};

Last but not least create 2 instances: one having a type of Duck, second one having a type of WeirdApple:

const duck: Duck = {
quack: () => console.log('Im a duck and I can quack!'),
};
const apple: WeirdApple = {
quack: () => console.log('I am an apple but I quack as well!'),
};

It’s time to call makeDuckQuack with our objects:

makeDuckQuack(duck); // Everything is fine, no errors!
makeDuckQuack(apple); // Everything is fine too, no errors!

What happened here? TypeScript expected to pass an argument of type Duck, but we passed an apple which has a type of WeirdApple.
It didn’t throw any errors because both WeirdApple and Duck contain method quack! It’s not a duck but it quacks like a duck so we can say it is a duck.

IMPORTANT:
TypeScript’s structural typing means it will check types by the shape instead of the name.

Another important thing is that TypeScript does not only check if passed object has quack method. It checks if passed object has EXACT shape as Duck.

It mean’s that if we make a Duck interface to contain one more field like name, and at the same time we won’t add that field to WeirdApple it will give us an error:

interface Duck {
quack: () => void;
name: string;
};
const duck: Duck = {
quack: () => console.log('Im a duck and I can quack!'),
name: 'Mr. Duck',
};

Now if we try:

makeDuckQuack(apple);

We get the following output:

Argument of type ‘WeirdApple’ is not assignable to parameter of type ‘Duck’. Property ‘name’ is missing in type ‘WeirdApple’ but required in type ‘Duck’.

You see? We don’t even need ‘name’ property anywhere in makeDuckQuack function, but since this function requires an argument of type Duck we need to always pass an object which will contain every single thing from Duck interface, for now it is: quack method which returns nothing and name property which is a string.

Note: quack methods of duck and apple have the same type but implementation is a little different, duck console logs ‘Im a duck and I can quack’ and apple logs: ‘I am an apple but I quack as well!’. Even if the implementations are different it’s still okay because both of those methods are of the same type, they require no arguments to be passed and they return nothing.

Cons of duck typing

Although it looks really good it can lead to some unexpected errors.
For example, study the following code:

type Age = number;
type Height = number;
interface Human {
age: Age,
height: Height,
};
const human1: Human = {
age: 25,
height: 181,
};
const human2: Human = {
age: 31,
height: 175,
};
const sumHeights = (height1: Height, height2: Height) => {
return height1 + height2;
};

Can you see what kind of error I am talking about?
We can pass age to sumHeights and it will be okay for our compiler:

sumHeights(human1.height, human2.age);

But it doesn’t make any sense. What’s the purpose of adding age to height? Typescript is all good about it because both Height and Age types are supposed to be type of number.

How to deal with that?

To make sure you cannot pass variable with type of Age to an argument which expects type of Height you have to use something which is called branding.

Let’s change our types to use branding:

type Age = number & { _brand: 'age' };
type Height = number & { _brand: 'height' };

Note that you don’t have to change anything in Human interface but you have to cast type inside its instances:

interface Human {
age: Age,
height: Height,
};
const human1: Human = {
age: 25 as Age, // without casting you will get an error
height: 181 as Height, // without casting you will get an error
};
const human2: Human = {
age: 31 as Age, // without casting you will get an error
height: 175 as Height, // without casting you will get an error
};

Now if you try to run:

sumHeights(human1.height, human2.age);

You will get the following error:

Argument of type 'Age' is not assignable to parameter of type 'Height'.
Type 'Age' is not assignable to type '{ brand: "height"; }'.
Types of property 'brand' are incompatible.
Type '"age"' is not assignable to type '"height"'

Now we’re safe. You cannot sum height and age anymore. Only type you can pass to function is Height.

Of course you could cheat and cast age to type Height but this is intentional, previous mistake where you didn’t have branding and you passed Age instead of Height is more unintentional.

Finish

I’m happy you made it to the end!

Thank you for reading.

--

--