Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why are TypeScript arrays covariant?

Tags:

typescript

TypeScript allows us to alias an array-typed variable with a variable of a supertype (TypeScript arrays are covariant):

const nums: number[] = [];
const things: (number | string)[] = nums;
things.push("foo");
nums[0] *= 3;
console.log(nums[0]); // `NaN` !!

Why? This seems like a nice place to protect us from runtime errors. Given how Java was mocked for having covariant arrays, it seems this was an intentional TS feature.

This was asked by someone else on a stale TypeScript issue, but I didn't see any answers.

like image 889
Max Heiber Avatar asked Mar 28 '20 18:03

Max Heiber


People also ask

Are arrays covariant?

Arrays Are Covariant Arrays are said to be covariant which basically means that, given the subtyping rules of Java, an array of type T[] may contain elements of type T or any subtype of T .

Why are arrays invariant?

Arrays in Kotlin are invariant, which means that an array of a specific type cannot be assigned to an array of its parent type. It is not possible to assign Array<Integer> to Array<Any> . This provides implicit type safety and prevents possible runtime errors in the application.

Are lists covariant in Scala?

The Scala standard library has a generic immutable sealed abstract class List[+A] class, where the type parameter A is covariant. This means that a List[Cat] is a List[Animal] .


2 Answers

As you've noted, array covariance is unsound and can lead to errors at runtime. One of TypeScript's Design Non-Goals is

  1. Apply a sound or "provably correct" type system. Instead, strike a balance between correctness and productivity.

which means that if some unsound language feature is very useful, and if requiring soundness would make the language very difficult or annoying to use, then it's likely to stay, despite potential pitfalls.

Apparently there comes a point when it is "a fool's errand" to try to guarantee soundness in a language whose primary intent is to describe JavaScript.


I'd say that the underlying issue here is that TypeScript wants to support some very useful features, which unfortunately play poorly together.

The first is subtyping, where types form a hierarchy, and individual values can be of multiple types. If a type S is a subtype of type T, then a value s of type S is also a value of type T. For example, if you have a value of type string, then you can also use it as a value of type string | number (since string is a subtype of string | X for any X). The entire edifice of interface and class hierarchy in TypeScript is built on the notion of subtyping. When S extends T or S implements T, it means that S is a subtype of T. Without subtyping, TypeScript would be harder to use.

The second is aliasing, whereby you can refer to the same data with multiple names and don't have to copy it. JavaScript allows this: const a = {x: ""}; const b = a; b.x = 1;. Except for primitive data types, JavaScript values are references. If you tried to write JavaScript without passing around references, it would be a very different language. If TypeScript enforced that in order to pass an object from one named variable to another you had to copy all of its data over, it would be harder to use.

The third is mutability. Variables and objects in JavaScript are generally mutable; you can reassign variables and object properties. Immutable languages are easier to reason about / cleaner / more elegant, but it's useful to mutate things. JavaScript is not immutable, and so TypeScript allows it. If I have a value const a: {x: string} = {x: "a"};, I can follow up with a.x = "b"; with no error. If TypeScript required that all aliases be immutable, it would be harder to use.

But put these features together and things can go bad:

let a: { x: string } = { x: "" }; // subtype
let b: { x: string | number }; // supertype 
b = a; // aliasing
b.x = 1; // mutation
a.x.toUpperCase(); // 💣💥 explosion

Playground link to code

Some languages solve this problem by requiring variance markers. Java's wildcards serve this purpose, but they are fairly complicated to use properly and (anecdotally) considered annoying and difficult.

TypeScript has decided not to do anything here and treat all property types as covariant, despite suggestions to the contrary. Productivity is valued above correctness in this aspect.


For similar reasons, function and method parameters were checked bivariantly until TypeScript 2.6 introduced the --strictFunctionTypes compiler option, at which point only method parameters are still always checked bivariantly.

Bivariant type checking is unsound. But it's useful because it allows mutations, aliasing, and subtyping (without harming productivity by requiring developers to jump through hoops). And method parameter bivariance results in array covariance in TypeScript.


Okay, hope that helps; good luck!

like image 198
jcalz Avatar answered Oct 12 '22 14:10

jcalz


This isn't an issue of covariance; it's an issue of aliasing. Forbidding array covariance is incredibly frustrating, so much so that even languages that are almost entirely invariant (Swift) include an exceptions for Arrays. (Swift avoids the problem you've shown here by preventing aliasing, so this bug is not possible in Swift.)

Imagine a function that accepted a list of optional numbers:

function sum(values: (number|undefined)[]): number {
  return values.reduce((s: number, x?: number) => s + (x ?? 0), 0)
}

Imagine if you could not pass number[] to this function. It is not hard to understand why they did not impose that.

The frustration is that JavaScript makes it too easy to mutate an alias, but this is a broad class of problem that's much larger than the covariance case. IMO, the following is the major headache that TypeScript should be helping us avoid (difficult as that might be to do given how TS works). If this code would raise an error, it solve the covariance problem as a special case:

const nums: number[] = [];
const things: number[] = nums;
things.push(1);
nums[0] *= 3;
console.log(things[0]); // `3` !!
like image 27
Rob Napier Avatar answered Oct 12 '22 13:10

Rob Napier