Closed pixelzoom closed 2 years ago
Slack discussion with @jonathanolson:
Jonathan Olson 2:16 PM
Ahhh, I think it's because of the link and we'd need the covariant/contravariant type control. If you could set a Property<Child>
to Property<Parent>
, then something else could call set( something: Property )
on it that could break it. read-only should theoretically be super safe, but writable ones would suffer from that type issue
I think Java allowed that in its generics, and failed during runtime if you did it
Chris Malley 2:19 PM
I’m not clear on why/where this is would make it possible to set Property<Child>
to Property<Parent>
.
Jonathan Olson 2:20 PM Do you mean what the potential break would be type-wise or runtime-wise?
Chris Malley 2:21 PM
Seems detectable type-wise. If I have foo: Child
and try to do foo = new Parent()
isn’t that type-wise? Why is it different for Property?
Jonathan Olson 2:21 PM
// class A, class B extends A, class C extends A
const a: Property<A> = new Property<B>( new B() );
a.value = new C();
// Now a Property<B> would point to a C
Chris Malley 2:22 PM
I see. And this is due to the declaration in the base class:
abstract thing: Property<Parent>
And the error manifests only when the “shape” of Child becomes different than Parent, by adding something to Child’s API.
Jonathan Olson 2:23 PM Otherwise don't they have the same structural type, thus are assignable?
Chris Malley 2:23 PM Right. TS doesn’t care at all about the class hierarchy.
Jonathan Olson 2:23 PM
yup
Ahh, link
DOES have the issue potentially too, breakable
wait, only breakable by types
Chris Malley 2:25 PM So I guess I’m stuck having to parameterize the base class, like:
class Parent {}
class Child extends Parent {
readonly foo = 5;
}
abstract class Optics<T> {
abstract thing: Property<T>
}
class SubOptics extends Optics<Child> {
thing: Property<Child>;
constructor( thing: Property<Child> ) {
super();
this.thing = thing;
}
}
Jonathan Olson 2:25 PM
Perhaps there's a way of saying a IReadOnlyProperty<Child>
IS a IReadOnlyProperty<Parent>
for typescript
I'll investigate better ways, I'm sure this comes up
Chris Malley 2:25 PM Sure is a common OO pattern.
Jonathan Olson 2:26 PM Looking into it
Chris Malley 2:49 PM What’s interesting to me in Geometric Optics is that my use case is DerivedProperty, not Property. So there is zero chance that someone can set the value to something incorrect, because it’s not settable. But I’m being bound by the same constraints as Property, which is settable.
Here's a self-contained synthetic case that reproduces the problem above without relying on anything in axon. It shows that TypeScript is revealing a real type error, not that there is a fundamental problem with axon or DerivedProperty.
type FakeListener<T> = ( value: T ) => void;
class FakeProperty<T> {
listeners: FakeListener<T>[] = [];
addElement( t: T ) {
this.listeners.forEach( listener => listener( t ) );
}
}
class Parent {}
class Child extends Parent {
speak() {
return 'hello';
}
}
abstract class Optics {
abstract thing: FakeProperty<Parent>
}
class SubOptics extends Optics {
thing: FakeProperty<Child>; // TYPE ERROR
constructor( thing: FakeProperty<Child> ) {
super();
this.thing = thing;
}
}
const t = new SubOptics( new FakeProperty<Child>() );
t.thing.listeners.push( ( child: Child ) => {
child.speak();
} );
t.thing.addElement( new Parent() ); // TYPE ERROR
This is because TypeScript correctly identifies type error due to attempted covariant parameter types, which are not type safe. Only contravariant parameter types are type safe. If you add ts-ignore
or use any
and run the code anyways, you get this error:
Uncaught TypeError: child.speak is not a function
You can read more about covariance, contravariance, bivariance and invariance here: https://en.wikipedia.org/wiki/Covariance_and_contravariance_(computer_science)
Also note in particular the section "Covariant method parameter type" which discusses that some languages like Eiffel purposefully avoid this type check in order to support cases like this.
It also looks like the solution described in https://github.com/microsoft/TypeScript/issues/10717#issuecomment-768569674
matches what I originally discussed with @pixelzoom--adding a type parameter to optic like Optic<T extends OpticsShapes>
Programming TypeScript (Cherney), pages 116-118, has a good summary of variance:
A <: B
means "A is a subtype of or the same as type B"A >:B
means "A is a supertype of or the same as type B" ... four sorts of variance: Invariance: You want exactly aT
Covariance: You wanta <: T
Contravariance: You wanta >: T
Bivariance: You're OK with eithera <: T
ora >: T
... and how TypeScript addresses variance:
... When talking about types, we say that TypeScript shapes (objects and classes) are covariant in their property types. That is, for an object A to be assignable to an object B, each of its properties must be
<:
its corresponding property in B. ... In TypeScript, every complex type is covarient in its members - object, classes, arrays, and function return types - with one exception: function parameter types, which are contravariant.
In @samreid's synthetic example, this statement in the Optics
base class
abstract thing: FakeProperty<Parent>
...sets up the requirement that thing.link
signature is:
link( listener: PropertyLinkListener< Parent >, options?: any ): void
The SubOptics
subclass of Optic then defines:
thing: FakeProperty<Child>;
...which will have a link
signature of:
link( listener: PropertyLinkListener<Parent>, options?: any ): void
Since TS requires function parameters to be contravariant >:
, this is where the problem occurs. The superclass requires listener
to be PropertyLinkListener<Parent>
, but the subclass provides PropertyLinkListener<Child>
, and Child <: Parent
.
We could presumably lose some type safety AND get this by doing something like link<U extends T>( listener: PropertyLinkListener<U>, options?: any )
, however then you could add an overly-specific link listener (e.g. add a Path listener to a Node Property).
Now that I understand this better, I don't think we should try to circumvent TS's variance implementation. I agree with @samreid that the correct approach in geometric-optics would be Optic<T extends OpticsShapes>
. I probably won't actually do that in geometric-optics, and will stick with a "one size fits all" OpticsShapes.
So closing this issue.
I'm able to get the following to typecheck:
class Base {
foo: number;
constructor() {
this.foo = 5;
}
}
class Subtype extends Base {
bar: string;
constructor() {
super();
this.bar = 'hi';
}
}
const c: IReadOnlyProperty<Subtype> = new Property<Subtype>( new Subtype() );
const d: IReadOnlyProperty<Base> = c;
console.log( d.value );
It looks like it was a combination of linkAttribute and TinyProperty not using the IProperty interface in its emitter parameters.
@samreid can you review and verify?
I'm adding back in linkAttribute that returns any
in another commit.
Good discovery @jonathanolson. The main idea is that IReadOnlyProperty
and even IProperty
omit the problematic methods with contravariant parts from their interface. Therefore I'm able to put:
class Lens extends Optic {
readonly shapesProperty: IReadOnlyProperty<LensShapes>;
and it passes the type checker.
From the example in the top comment, if we change abstract thing: Property<Parent>
to abstract thing: IProperty<Parent>
, everything else works and type checks:
class Parent {}
class Child extends Parent {
readonly foo = 5;
}
abstract class Optics {
abstract thing: IProperty<Parent>
}
class SubOptics extends Optics {
thing: Property<Child>;
constructor( thing: Property<Child> ) {
super();
this.thing = thing;
}
}
It is sort of "by accident" that IProperty
omits the problematic parameters (parameters that break the contravariance rule) from its interface, but it's a convenient accident. If those parts could be removed from the full Property API itself, then we could use Property
instead of IProperty
here. But I'm not sure if that would be possible/easy.
I think the next step is to discuss with @pixelzoom.
Also assigning to @jonathanolson to discuss the possibility of removing the other problematic parts from the Property API.
If those parts could be removed from the full Property API itself, then we could use Property instead of IProperty here.
But what about cases supporting TinyProperty?
It is sort of "by accident"
Somewhat designed, I want to keep it covariant.
the possibility of removing the other problematic parts from the Property API.
I don't see how that's possible. Property includes a lot of fields we don't want on TinyProperty (and wouldn't want to fake).
Thanks, let's hear from @pixelzoom next, and see if he wants to use this strategy in the Geometric Optics case where this problem was reported.
Reopening https://github.com/phetsims/geometric-optics/issues/273 to investigate whether this works in practice in Geometric Optics.
Working nicely now in Geometric Optics, see https://github.com/phetsims/geometric-optics/issues/273#issuecomment-993826654. Thanks!
Not sure what (if anything) still needs to be done. So assigning to @samreid and @jonathanolson to discuss.
Sounds good, I think this issue can be closed.
This is a problem that I'm running into in Geometric Optics. @samreid and I worked on this for awhile, and didn't get anywhere.
The basic idea is that an abstract superclass declares an
abstract Property<T>
, where T is some base type. Then the subclass defines thatProperty<S>
, where S is a subclass of T.Here's a minimal example:
There are no TS errors in the above example. But if we make this change:
Then
thing
in this line:gets flagged with this error: