JSMonk / hegel

An advanced static type checker
https://hegel.js.org
MIT License
2.1k stars 59 forks source link

Covariance / Contravariance for generics? #273

Open MaxGraey opened 4 years ago

MaxGraey commented 4 years ago

Seems it doesn't support in hegel?

class Base {}
class Foo extends Base {}

const fooArr: Array<Foo> = [new Foo()];
const baseArr: Array<Base> = fooArr;

playground

raveclassic commented 4 years ago

It would be so great if Hegel supported explicit variance annotations like it's done in Scala: https://docs.scala-lang.org/tour/variances.html

JSMonk commented 4 years ago

@MaxGraey, yes. Because the problem is next (I describe it in details in Array Subtyping ):

// Your code
class Base {}
class Foo extends Base { someMethod() {} }

const fooArr: Array<Foo> = [new Foo()];
const baseArr: Array<Base> = fooArr;

// Runtime error code
baseArr[1] = new Base;
fooArr[1].someMethod();
MaxGraey commented 4 years ago

Right. And runtime exception is normal behaviour for C#, Java. Rust support this only for traits and generic functions. But as I understand Hegel doesn't have interfaces or traits?

trusktr commented 4 years ago

No runtime exception is better (in my opinion!). :+1:

I can understand the limitation though. What if the type checker can be smarter and it can allow certain things to happen with the array?

For example, if we are adding items to an Array<Foo> in some place that expects an Array<Base>, then that should not be allowed. But, if we're only reading the values from the array, that should be perfectly fine. So I think the type system can be improved, and can be more accurate depending on what the code is trying to do.

For example,

no type error in this code:

class Base { baseMethod() {} }
class Foo extends Base { someMethod() {} }

const fooArr: Array<Foo> = [new Foo()];
const baseArr: Array<Base> = fooArr; // ok, no error here
baseArr[0].baseMethod() // ok

type error in this code:

class Base { baseMethod() {} }
class Foo extends Base { someMethod() {} }

const fooArr: Array<Foo> = [new Foo()];
const baseArr: Array<Base> = fooArr; // ok
baseArr.push(new Base()) // ERROR

It would throw a type error here because it would know that although the usage site is Array<Base> the assigned array is an Array<Foo> (and it would know similar to passed args). If it knows that information, then it knows not to allow someone to add incompatible items to the array.

If the type checker tracks both the definition site and the assignment (or arg passing) site, then it can know about how the arrays will be used. If for some reason it can not tell, then it can fall back to the current behavior. But maybe it can always tell?

I wonder how much overhead such sort of type checking would add. It would be very nice though. It would allow more possibilities than the current.

kaleb commented 4 years ago

Is there a way to signify that an array is immutable? If so this should not be an issue for immutable arrays.

JSMonk commented 4 years ago

@kaleb . Yes, we have a special type called $Immutable which creates immutable arrays. And with the immutable array, this case will work exactly as @trusktr described. We currently have few bugs with assign to immutable array, but it will be fixed soon.

class Base { baseMethod() {} }
class Foo extends Base { someMethod() {} }

const fooArr: $Immutable<Array<Foo>> = [new Foo()];
const baseArr: $Immutable<Array<Base>> = fooArr; // ok
baseArr.push(new Base()) // ERROR

You can try it in Playground.

leunam217 commented 4 years ago

I don't think that it would bring a lot of value as you can construct variant containers with immutability. imagen

thecotne commented 4 years ago

@leunam217 immutability should be part of type system and should not leak artifacts to production code

you have introduced wrapper class but it is only used for type checking

leunam217 commented 4 years ago

@thecotne I'm not sure to understand your point. But it was just a comment saying that with the current features we can simulate covariant wrappers types. Sorry if it was confusing. Thanks for your awesome work, I think that the hegel phylosophy is the way to go.

trusktr commented 4 years ago

Yes, we have a special type called $Immutable which creates immutable arrays. And with the immutable array, this case will work exactly as @trusktr described.

That's great to know! However in my example I was imagining it with a mutable array (because the mutability is useful).

What I mean in that example is that the push would fail because although the type of baseArr is Array<Base>, there is enough code context that Hegel can understand that the array is still of type Array<Foo>, and can still prevent errors.

So the error would be more like

baseArr.push(new Base()) // ERROR, Can not push 'Base' to 'Array<Base>' which is derived from 'Array<Foo>'

or something (bikesheddable wording).

In other words, if Hegel has broader context, it can prevent errors, even if the types are more generic. I don't think I've seen that in a language before (but then again, my experience with typed languages is mainly C/C++, Java, and TypeScript). Is there any other language that keeps track of a broader context like that?

trusktr commented 4 years ago

The context could also be lost. For example, if someone write a library, where the entire library is this:

export class Base { baseMethod() {} }

export function doSomethingWithArray(arr: Array<Base>) {
  baseArr.push(new Base()) // ok
}

and the author compiles that to plain JS, then in that case there is no possible context that Hegel could infer from it. So in that case, the line

baseArr.push(new Base())

is totally ok, there is no type error because there is no context to check against. So these sorts of error-preventing type errors can only happen when the broader context is known (otherwise, there's nothing we can do).

If the end user of that library is also using Hegel, and imports the function, then this does cause a type error:

import {doSomethingWithArray, Base} from 'the-lib'

class Foo extends Base { someMethod() {} }

const fooArray = [new Foo()]
doSomethingWithArray(fooArray) // this context causes a type error, f.e. Can not push 'Base' to 'Array<Base>' which is derived from 'Array<Foo>'

See what I mean? Knowing the context of the code, Hegel could prevent certain errors (preventing having a non-Foo inserted into the lib user's Array<Foo>), while the array is not immutable. The user could, for example, decide to write some different code:

import {doSomethingWithArray, Base} from 'the-lib'

class Foo extends Base { someMethod() {} }

const fooArray: Array<Base> = [new Foo()]
doSomethingWithArray(fooArray) // No error, user made the context more generic.

Now the user can pass that mutable array into the function, athough they may now face other limitations like now being able to call Foo methods on the items. There's trade-offs, but either way the user chooses, the errors can be prevented while still having mutable arrays.