Untyped Typescript or Error Prone Covariance

Some time ago I wrote this short article about co- and controvariance in Kotlin and how these features help the developer to write less error prone code. When I talked with some colleagues about TypeScript, I recognized that, despite some experiences I made with small pet projects, I do no know about type variance in TypeScript. Thus, I decided to take a look and was surprised. TypeScript is not that well-typed as I thought…

Experiments with Dogs and Cats

My first approach was to read the specs. With TypeScript, the specification is the documentation at the main site and the implementation of the compiler. After spending several minutes searching and not finding anything helpful, I decided to just test what works with one of my examples from my previous article: cats and dogs.

abstract class Animal {
    constructor( readonly kind: string,
                 readonly name: string) {}
    ...
}
class Dog extends Animal {
    constructor(name: string, readonly taxNumber: string) {
        super("Dog", name);
    }
    ...
}
class Cat extends Animal {
    constructor(name: string) {
        super("Cat", name);
    }
    ...
}

You see, in this world, dogs are more controlled than cats by the law. They need to have tax numbers. If you want to print all tax numbers of an array of dogs, this is simple to do:

function printTaxNumbers(dogs: Dog[]) {
    const output =
        dogs
        .map((dog) => dog.taxNumber)
        .join(", ");
    console.log(output);
}

To have some fun, you may want to add a cat to an array of animals:

function addCatToArray(animals: Animal[]) {
    animals.push(new Cat("The added cat!"));
}

Up to now, everything looks fine and well-typed. Unfortunately if you combine both functions you easily create a faulty application:

const dogs: Dog[] = 
  [new Dog("Hasso", "654"), 
   new Dog("Rufus", "987")];
addCatToArray(dogs);
printTaxNumbers(dogs);

Now, I have a variable dogs of the type Dog[] that actually contains a cat. The output of this code is:

654, 987,

The cat has no tax number, thus cat.taxNumber evaluates to null which is transformed to an empty string. The error in the code is totally hidden and may lead to more and potential worse problems later.

Covariance is the Problem

The problem here is that the function addCatToArray expects a parameter of type Animal[]. It gets a parameter of the actual type Dog[]. Now TypeScript comes to this conclusion: A Dog is subclass of Animal and therefore everything you can do with an Animal you can do with a Dog. Therefor, everything you can do with an array of Animal objects you can also do with an array of Dog objects. This is called covariance and, as we can see, in this case it does not work.

The problem here is the mutability of arrays. If you would have an immutable array, covariance would work. Thus, other programming languages, e.g. Kotlin, allow the developer to explicitly specify for a container type, such as Array, if it supports covariance or not.

Structural Compatibility

In TypeScript two types are compatible if they have a compatible structure. It is not of importance if the types are related in some kind of type hierarchy. Thus you can easily assign objects from different domains:

class Pet {
   constructor(readonly name:string) {}
}
class PetWithTaxNumber extends Pet {
   constructor(readonly name:string, readonly number:string) {
    super(name);
} }

class Person {
  constructor(readonly name:string) {}
}
class EmployeeWithPersonnelNumber extends Person {
    constructor(readonly name:string, readonly number:string) {
        super (number)
} }

...
const myKitty = new PetWithTaxNumber("kitty", "12345678");
const meAtWork = new EmployeeWithPersonnelNumber("Torsten Fink", "MI5-007");
const neighboursDog : Pet = meAtWork;
const neighbour : Person = myKitty;

Now I have a neighboursDog with its specified type Pet that references a Person and a neighbour with its specified type Person that references a cat. This has nothing to do with co- oder contra variance. But, firstly it is important to understand how TypeScript works, and secondly, being disappointed by TypeScript, this had to be let out. 🙂

Structural Compatibility Covariance for Arrays

Now, let us have a look how this structural compatibility mechanism works with container types such as Array. I use the same classes as in the example before.

const myPets : Pet[] = [new Pet("kitty")];
const myTaxedPets : PetWithTaxNumber[] = 
  [new PetWithTaxNumber("taxed kitty", "12334")];

const myPersons : Person[] = [new Person("Me Person")];
const myEmployees : EmployeeWithPersonnelNumber[] = 
  [new EmployeeWithPersonnelNumber("Myself", "ABCD")];

let otherPets : Pet[] = myPets;
otherPets = myTaxedPets;
otherPets = myPersons;

let otherTaxedPets: PetWithTaxNumber[] = myTaxedPets;

// does not work because it is structural incompatible
otherTaxedPets = myPets; 
   
otherTaxedPets = myEmployees;

You can see, the same things you can do with the basic types, you can do with arrays of that type. otherPets has the type Pet[]. You can assign arrays of subclasses such as PetWithTaxNumber[] and arrays of structural compatible classes, such as Person[].

You cannot assign classes wich are structural incompatible. otherTaxedPets has the type PetWithTaxNumber[]. Thus, objects of type Pet[] cannot be assigned. They miss the property number.

To Sum it Up: Duck Types are Dangerous

Being a big fan of static type systems, I realized that actually I am a big fan of static nominal type system. This means the name of types matters, because names bound types to domains. And, developing software is mainly about modeling the domains of the real world. Typescript uses Duck typing and only ensures structural compatibility. While IMHO this is much better than an untyped programming language, such as JavaScript, there is still a lot of space for undetected errors, which would be detected with a real type system.

Addendum 1: Another Unsafe Example

After publishing this article, a colleague of mine sent me one of his favorite examples of unsafe TypeScript code. It is not related to covariance but so short, clean, and bad that I had to add it:

type ReqName = {name: string}
type OptName = {name: string | undefined}

const reqName: ReqName = {name: 'some'};
const optName: OptName = reqName;
optName.name = undefined;

const definedValue: string = reqName.name // <- undefined, kaboom

You can see that the type ReqName has a member name that is a string. OptName is a type with a member string that may be undefined. Because OptName is more than ReqName they are structurally compatible. You can assign a ReqName-object to a OptName-typed reference. This would be safe, if the objects were immutable. But, being mutable, I can change the member value name to undefined using the reference optName. Now, there is a reference reqName which member value name is changed to undefined despite his type forbids undefined values!

This is another very big argument to not use mutable data.

Leave a Reply