Hacker News new | ask | show | jobs
by jonsterling 3156 days ago
TypeScript, the language which only just this month added a flag to turn off their completely incorrect subtyping rules for functions! A flag!

I remember reporting this bug years ago, and they said it was "by design, since JS programmers prefer to think of functions as covariant in their input". Well, I prefer to think of 2+2 as equalling 5.

2 comments

Design decisions like this are part of what makes TypeScript much more enjoyable to work in than other JS type systems (e.g. Closure, Flow).

TS tends to make pragmatic tradeoffs between sound typing, and the way JS developers tend to architect code. I'm glad they do.

I don't know nearly enough about type systems to understand this. Can you explain what subtyping rules are, and how TypeScript's are wrong? And can you explain what "covariant in their input" means?
Covariant: A variable needs to have an Animal. You can put a Cat in the variable.

Contravariant: A variable needs to have a function that accepts Animals. You can't put a function that accepts only Cats, or it will crash on other kinds of animal. But you can put a function that accepts all LivingThings.

So when something is covariant you can use a more specific type, and when it's contravariant you can use a more generic type.

When it comes to function parameters, typescript lets you use either. You can replace any type with any related type, even if you're going in the wrong direction. They did this to make certain use cases simpler, at the cost of weaker typing.

Which use cases are those?? Are any of them actually real life, practical use cases?
If a function takes an array of Animal as an argument, the "intuitive" assumption is that you can pass in a Cat[] to it since it's probably processing the array items and a Cat is an Animal.

In usual Javascript patterns, this intuition works, but it's not sound, since arrays are mutable; the function could write to the array, appending a Dog or a Potoo or a Jellyfish- valid for an Animal[] argument, but not sound for a Cat[].

In practice, in most JS code I've seen, arrays are constructed once and then are passed around as immutable collections, so Typescript's unsoundness saves a lot more casting than it introduces errors.

The real problem here is that mutable data structures aren’t entirely type sound. The solution here is to split collections into read only and mutable types, but people balk at this.

At one point long ago I did some experiments with this, and found that the most of the unique method signatures necessary to represent the data structures we are familiar with are concentrated in the mutators.

That is, the interface differences between read only data structures are pretty small. So you don’t double the surface area by splitting read from write. It’s more like 25%, and this might be partially offset by simplifications in code that has to scan multiple types of collections.

There's nothing wrong with mutable data structures from a type soundness perspective. We know how to do it properly. But you need to get the type system right in order to include mutable data structures; often people get this wrong, and it leads to all sorts of frustration...
Do you have any examples lying around of what the interface differences would look like?
https://github.com/Microsoft/TypeScript-Handbook/blob/master...

The example seems like a valid case. The type of event is expressed in a separate parameter, so being strict here requires pointless casting and doesn't actually make things safer.

Whether you think this type weakness is worth the downsides is up to you.

That helps, thanks.

Can you explain the naming? What sort of variance is the second case contra?

The first one is the more natural one, and so the opposite gets to be "contra", I think.
Suppose you have a type hierarchy where a Tabby is a type of Cat that is a type of Animal.

Now, say you have a function that takes a Cat as an argument and returns a Cat. What types can you use instead of the Cats and still remain type safe? Well, you could pass in a Tabby or return a Tabby and everything would be fine. This is a subtype rule that the compiler can check.

What about something a little trickier? Say you have a map function that maps from a list of Cats to another list of Cats. The map function takes two arguments: a list of Cats and an arbitrary function that takes a Cat and returns a Cat. For each item in the input list, the function is called on it and the returned Cat is added to the output list.

Now, can we replace the Cat to Cat input function with a function that has different types and still have the types make sense?

Let's try a function that takes a Tabby as an argument and returns a Cat (usually denoted Tabby->Cat). This won't work as we could have something that isn't a Tabby in the input list. What about a function Animal->Cat? This would work OK as every Cat in the input list is also an Animal.

Similarly, what if we try a Cat->Tabby function? This would work too as a Tabby is always a Cat. What about Cat->Animal? No, as it might produce a Dog and we only want Cats.

Notice the difference between the arguments and return types? For arguments we can replace a Cat with an Animal but, for returns, we can only go the other way. We say that we are contravariant in the argument type but covariant in the return type.

There are other forms. We say we are invariant if we can't change the type from the original stated type. We say we are bivariant if we can replace Cat with either Animal or Tabby.

So, what did Typescript do wrong? As I understand it, Typescript was (is?) bivariant in both argument and return types. This means that the type system would, say, accept a Tabby->Cat function for a Cat->Cat function argument and then fail at runtime.

Is this a heinous crime? A matter of opinion I guess. It's definitely wrong from a type system perspective but plenty of type systems let this sort of thing pass and are successful.

BTW this subtyping complexity isn't just for function arguments. It's also an issue with collection types e.g. what can yuo replace a list of Cats with? But this comment is way too long already.

Here is a good and down-to-earth explanation: https://www.stephanboyer.com/post/132/what-are-covariance-an...