hegel icon indicating copy to clipboard operation
hegel copied to clipboard

Covariance / Contravariance for generics?

Open MaxGraey opened this issue 4 years ago • 11 comments

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

MaxGraey avatar Jun 12 '20 07:06 MaxGraey

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

raveclassic avatar Jun 12 '20 07:06 raveclassic

@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();

JSMonk avatar Jun 12 '20 12:06 JSMonk

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?

MaxGraey avatar Jun 12 '20 12:06 MaxGraey

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.

trusktr avatar Jul 04 '20 01:07 trusktr

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

kaleb avatar Jul 08 '20 11:07 kaleb

@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.

JSMonk avatar Jul 09 '20 22:07 JSMonk

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

leunam217 avatar Aug 14 '20 16:08 leunam217

@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

thecotne avatar Aug 14 '20 17:08 thecotne

@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.

leunam217 avatar Aug 14 '20 19:08 leunam217

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 avatar Sep 04 '20 21:09 trusktr

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.

trusktr avatar Sep 04 '20 21:09 trusktr