Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Can Multilink/DerivedProperty infer the callback parameter types? #395

Closed
zepumph opened this issue May 9, 2022 · 32 comments
Closed

Can Multilink/DerivedProperty infer the callback parameter types? #395

zepumph opened this issue May 9, 2022 · 32 comments

Comments

@zepumph
Copy link
Member

zepumph commented May 9, 2022

@samreid and I ran into this while trying to figure out how to get rid of unused parameters in callbacks for DerivedProperty and Multilink (in phetsims/chipper#1230). The current behavior is that you must provide the right types for the callback, because that is how the Parameter types are inferred. They aren't seeming to be inferred from the dependencies arrays (unfortunately).

Tagging @jonathanolson because he was the original converter to typescript.

Ideally we could specify any USED parameters needed for the callback, and they would have correct type information gathered from the list of dependencies provided.

@zepumph zepumph self-assigned this May 9, 2022
@zepumph
Copy link
Member Author

zepumph commented May 9, 2022

Here is a snippet I made that gave me hope that Typescript itself is OK with provided less parameters for a callback. The issue seems to be about how the type inference is happening for Multilink. Furthermore Emitter is a great example of how we always provide the types, so perhaps that could be a worst-case.

// Proof that callbacks support provided var args generally
class Hello<Parameters extends any[]> {

  decide( t: Parameters, callback: ( ...params: Parameters ) => void ): void {
    callback( ...t );
  }

}

// No type error
new Hello<[ boolean, boolean ]>().decide( [ true, true ], ( something: boolean, somethingAgain: boolean ) => { console.log( something, somethingAgain ); } );

// Type error because of three parameters
new Hello<[ boolean, boolean ]>().decide( [ true, true ], ( something: boolean, somethingAgain: boolean, somethingAgain2: boolean ) => { console.log( something, somethingAgain, somethingAgain2 ); } );

// No type error
new Hello<[ boolean, boolean ]>().decide( [ true, true ], ( something: boolean ) => { console.log( something ); } );

@zepumph
Copy link
Member Author

zepumph commented May 9, 2022

Here is a snippet from stack overflow to help me see that we could create a tuple type of length N, but I didn't know if it would be possible to do that by inferring from a parameter type where all we know is that it extends any[].

https://stackoverflow.com/questions/52489261/typescript-can-i-define-an-n-length-tuple-type

type Tuple<T, N extends number> = N extends N ? number extends N ? T[] : _TupleOf<T, N, []> : never;
type _TupleOf<T, N extends number, R extends unknown[]> = R['length'] extends N ? R : _TupleOf<T, N, [ T, ...R ]>;

type Tuple9<T> = Tuple<T, 9>;
type Board9x9<P> = Tuple9<Tuple9<P>>;

@zepumph
Copy link
Member Author

zepumph commented May 9, 2022

Here is our working copy trying to get a hard coded number of dependencies to work:

Index: axon/js/Multilink.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/axon/js/Multilink.ts b/axon/js/Multilink.ts
--- a/axon/js/Multilink.ts	(revision 14fcabf46dde58eb1b0db4ec12c606954b538af1)
+++ b/axon/js/Multilink.ts	(date 1652132113304)
@@ -17,23 +17,32 @@
  */
 
 import axon from './axon.js';
-import { MappedProperties } from './DerivedProperty.js';
 import IReadOnlyProperty from './IReadOnlyProperty.js';
 
 // constants
 const GET_PROPERTY_VALUE = <T>( property: IReadOnlyProperty<T> ): T => property.get();
 
+
+// SO Gross
+type MappedProperties<T1, T2, T3, T4, T5> = T1 extends never ? [] :
+                                            T2 extends never ? [ IReadOnlyProperty<T1> ] :
+                                            T3 extends never ? [ IReadOnlyProperty<T1>, IReadOnlyProperty<T2> ] :
+                                            T4 extends never ? [ IReadOnlyProperty<T1>, IReadOnlyProperty<T2>, IReadOnlyProperty<T3> ] :
+                                            T5 extends never ? [ IReadOnlyProperty<T1>, IReadOnlyProperty<T2>, IReadOnlyProperty<T3>, IReadOnlyProperty<T4> ] :
+                                            [ IReadOnlyProperty<T1>, IReadOnlyProperty<T2>, IReadOnlyProperty<T3>, IReadOnlyProperty<T4>, IReadOnlyProperty<T5> ];
+
+
 const valuesOfProperties = <Parameters extends any[]>( dependencies: MappedProperties<Parameters> ): Parameters => {
   // Typescript can't figure out that the map gets us back to the Parameters type
   return dependencies.map( GET_PROPERTY_VALUE ) as Parameters;
 };
 
 // Type of a derivation function, that takes the typed parameters (as a tuple type)
-type Callback<Parameters extends any[]> = ( ...params: Parameters ) => void;
+type Callback<T1, T2, T3, T4, T5> = ( p1: T1, p2: T2, p3: T3, p4: T4, p5: T5 ) => void;
 
-export default class Multilink<Parameters extends any[]> {
+export default class Multilink<T1 = never, T2 = never, T3 = never, T4 = never, T5 = never> {
 
-  private dependencies: MappedProperties<Parameters> | null;
+  private dependencies: MappedProperties<T1, T2, T3, T4, T5> | null;
   private dependencyListeners: Map<IReadOnlyProperty<any>, () => void>;
 
   private isDisposed?: boolean;
@@ -43,7 +52,7 @@
    * @param callback function that expects args in the same order as dependencies
    * @param [lazy] Optional parameter that can be set to true if this should be a lazy multilink (no immediate callback)
    */
-  constructor( dependencies: MappedProperties<Parameters>, callback: Callback<Parameters>, lazy?: boolean ) {
+  constructor( dependencies: MappedProperties<T1, T2, T3, T4, T5>, callback: Callback<T1, T2, T3, T4, T5>, lazy?: boolean ) {
 
     this.dependencies = dependencies;
 
Index: ratio-and-proportion/js/discover/view/DiscoverScreenSummaryNode.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/ratio-and-proportion/js/discover/view/DiscoverScreenSummaryNode.ts b/ratio-and-proportion/js/discover/view/DiscoverScreenSummaryNode.ts
--- a/ratio-and-proportion/js/discover/view/DiscoverScreenSummaryNode.ts	(revision a1e0d01faa526ebef8ca2c626cd943ce08527af4)
+++ b/ratio-and-proportion/js/discover/view/DiscoverScreenSummaryNode.ts	(date 1652132113311)
@@ -19,6 +19,7 @@
 import RatioDescriber from '../../common/view/describers/RatioDescriber.js';
 import IReadOnlyProperty from '../../../../axon/js/IReadOnlyProperty.js';
 import EnumerationProperty from '../../../../axon/js/EnumerationProperty.js';
+import Multilink from '../../../../axon/js/Multilink.js';
 
 type RatioToChallengeNameMap = Map<number, { lowercase: string; capitalized: string }>;
 
@@ -81,14 +82,36 @@
     this.ratioToChallengeNameMap = ratioToChallengeNameMap;
 
     // This derivedProperty is already dependent on all other dependencies for getStateOfSimString
-    Property.multilink( [
+    const x = new Multilink( [
+
+        /*
+
+ With inProportionProperty commented out, these 4 have this error:
+        TS2322: Type 'Property ' is not assignable to type 'never'.
+         */
+
       targetRatioProperty,
       tickMarkViewProperty,
       ratioTupleProperty,
       ratioFitnessProperty,
-      inProportionProperty
-    ], ( currentTargetRatio: number, tickMarkView: TickMarkView, currentTuple: RAPRatioTuple, fitness: number, inProportion: boolean ) => {
+
+        /*
+        Works great with this commented in if we hard code MappedProperties to just work for 5 values, but the
+         current <=5 varargs implementation breaks this, but we couldn't get it to take less parameters just yet, it
+         thought it was all `never`. Commented in it says:
 
+        S2322: Type 'IReadOnlyProperty<boolean>' is not assignable to type 'IReadOnlyProperty<false> | IReadOnlyProperty<true>'.
+          Type 'IReadOnlyProperty<boolean>' is not assignable to type 'IReadOnlyProperty<false>'.
+            Type 'boolean' is not assignable to type 'false'.
+         */
+      // inProportionProperty
+    ],
+
+      // These have the correct typing! YAY! (number, TickMarkView,RAPRatioTuple,number,boolean)
+      ( p1,p2, p3,p4,p5 ) => {
+
+      // Type error, it knows it is a number
+      const x: string = p1;
       stateOfSimNode.innerContent = this.getStateOfSim();
       leftHandBullet.innerContent = this.getLeftHandState();
       rightHandBullet.innerContent = this.getRightHandState();

@zepumph
Copy link
Member Author

zepumph commented May 9, 2022

@samreid and I left this investigation thinking it would be good to create a minimal case and see if any typescript experts can assist with this challenge. Likely exactly what I want is impossible, something like this:

type Callback<Parameters, N> = N extends 0? Parameters : ( (...params: Parameters[0:N] ) => void ) | Callback<Parameters,N-1>; 

@samreid
Copy link
Member

samreid commented May 10, 2022

There is an interesting example here:

https://stackoverflow.com/a/70568063/1009071

function h<T extends any[] >(x: T): T;
h([1, 2, "hello"]); // =>  (string | number)[]

function h<T extends any[] | []>(x: T): T;
h([1, 2, "hello"]); // => [number, number, string]

I don't know how it works, but I tried putting | [] a few places in Multilink but couldn't get it working.

@samreid
Copy link
Member

samreid commented May 10, 2022

This patch has correct inference of the type and supports an arbitrary number of parameters in the callback:

Index: main/axon/js/DerivedProperty.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/main/axon/js/DerivedProperty.ts b/main/axon/js/DerivedProperty.ts
--- a/main/axon/js/DerivedProperty.ts	(revision 2ee1223d582611a473c6363b303af22771310a8a)
+++ b/main/axon/js/DerivedProperty.ts	(date 1652153246683)
@@ -27,7 +27,7 @@
 export type DerivedPropertyOptions<T> = SelfOptions & PropertyOptions<T>;
 
 // Maps tuples/arrays from T => IReadOnlyProperty<T>
-export type MappedProperties<Parameters extends any[]> = {
+export type MappedProperties<Parameters> = {
   [K in keyof Parameters]: IReadOnlyProperty<Parameters[K]>;
 };
 
Index: main/axon/js/Property.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/main/axon/js/Property.ts b/main/axon/js/Property.ts
--- a/main/axon/js/Property.ts	(revision 2ee1223d582611a473c6363b303af22771310a8a)
+++ b/main/axon/js/Property.ts	(date 1652153835891)
@@ -15,7 +15,7 @@
 import StringIO from '../../tandem/js/types/StringIO.js';
 import VoidIO from '../../tandem/js/types/VoidIO.js';
 import axon from './axon.js';
-import Multilink from './Multilink.js';
+import Multilink, { Callback } from './Multilink.js';
 import propertyStateHandlerSingleton from './propertyStateHandlerSingleton.js';
 import PropertyStatePhase from './PropertyStatePhase.js';
 import TinyProperty from './TinyProperty.js';
@@ -507,7 +507,7 @@
    * @param properties
    * @param listener function that takes values from the properties and returns nothing
    */
-  static multilink<Parameters extends any[]>( properties: MappedProperties<Parameters>, listener: ( ...params: Parameters ) => void ): Multilink<Parameters> {
+  static multilink<Parameters extends readonly any[]>( properties: MappedProperties<Parameters>, listener: Callback<Parameters> ): Multilink<Parameters> {
     return new Multilink( properties, listener, false );
   }
 
Index: main/axon/js/Multilink.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/main/axon/js/Multilink.ts b/main/axon/js/Multilink.ts
--- a/main/axon/js/Multilink.ts	(revision 2ee1223d582611a473c6363b303af22771310a8a)
+++ b/main/axon/js/Multilink.ts	(date 1652153969938)
@@ -23,15 +23,24 @@
 // constants
 const GET_PROPERTY_VALUE = <T>( property: IReadOnlyProperty<T> ): T => property.get();
 
-const valuesOfProperties = <Parameters extends any[]>( dependencies: MappedProperties<Parameters> ): Parameters => {
+const valuesOfProperties = <Parameters extends readonly any[]>( dependencies: MappedProperties<Parameters> ): Parameters => {
   // Typescript can't figure out that the map gets us back to the Parameters type
-  return dependencies.map( GET_PROPERTY_VALUE ) as Parameters;
+
+  // @ts-ignore
+  return dependencies.map( GET_PROPERTY_VALUE );
 };
 
 // Type of a derivation function, that takes the typed parameters (as a tuple type)
-type Callback<Parameters extends any[]> = ( ...params: Parameters ) => void;
+export type Callback<Parameters extends Readonly<any[]>> =
+  ( () => void ) |
+  ( ( p0: Parameters[0] ) => void ) |
+  ( ( p0: Parameters[0], p1: Parameters[1] ) => void ) |
+  ( ( p0: Parameters[0], p1: Parameters[1], p2: Parameters[2] ) => void ) |
+  ( ( p0: Parameters[0], p1: Parameters[1], p2: Parameters[2], p3: Parameters[3] ) => void ) |
+  ( ( p0: Parameters[0], p1: Parameters[1], p2: Parameters[2], p3: Parameters[3], p4: Parameters[4] ) => void ) |
+  ( ( p0: Parameters[0], p1: Parameters[1], p2: Parameters[2], p3: Parameters[3], p4: Parameters[4], p5: Parameters[5] ) => void );
 
-export default class Multilink<Parameters extends any[]> {
+export default class Multilink<Parameters extends readonly any[]> {
 
   private dependencies: MappedProperties<Parameters> | null;
   private dependencyListeners: Map<IReadOnlyProperty<any>, () => void>;
@@ -59,6 +68,8 @@
 
         // don't call listener if this Multilink has been disposed, see https://github.com/phetsims/axon/issues/192
         if ( !this.isDisposed ) {
+
+          // @ts-ignore
           callback( ...valuesOfProperties( dependencies ) );
         }
       };
@@ -74,6 +85,8 @@
 
     // Send initial call back but only if we are non-lazy
     if ( !lazy ) {
+
+      // @ts-ignore
       callback( ...valuesOfProperties( dependencies ) );
     }
 
Index: main/ratio-and-proportion/js/discover/view/DiscoverScreenSummaryNode.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/main/ratio-and-proportion/js/discover/view/DiscoverScreenSummaryNode.ts b/main/ratio-and-proportion/js/discover/view/DiscoverScreenSummaryNode.ts
--- a/main/ratio-and-proportion/js/discover/view/DiscoverScreenSummaryNode.ts	(revision d5730599aec919ca313f9aacefe85b5ecef0f388)
+++ b/main/ratio-and-proportion/js/discover/view/DiscoverScreenSummaryNode.ts	(date 1652154024986)
@@ -87,7 +87,7 @@
       ratioTupleProperty,
       ratioFitnessProperty,
       inProportionProperty
-    ], ( currentTargetRatio: number, tickMarkView: TickMarkView, currentTuple: RAPRatioTuple, fitness: number, inProportion: boolean ) => {
+    ] as const, ( currentTargetRatio: number, tickMarkView: TickMarkView, currentTuple: RAPRatioTuple, fitness: number, inProportion: boolean ) => {
 
       stateOfSimNode.innerContent = this.getStateOfSim();
       leftHandBullet.innerContent = this.getLeftHandState();

image

The cost of this approach:

  • as mentioned in https://stackoverflow.com/a/63322898/1009071 we need to specify as const on the array.
  • There were 3 other parts in Multilink that I couldn't get typed correctly (yet?), so they are marked with ts-ignore.

Before starting on this issue, I thought it would be too demanding to require developers to put as const at multilink (and potentially derivedproperty) sites. However, as mentioned in https://stackoverflow.com/a/63322898/1009071 it does seem part of the language to infer (T1|T2|T3)[] from a plain array, so I'm more inclined to advocate to the team to use as const in cases like these.

Where would it be acceptable to leave off the as const? If there are 0 parameters in the callback, you can get away with it. Also, if there is only one element in the array and 1 parameter in the callback, it will still type check without as const, but it won't shield you from accidentally putting too many parameters in the callback (all with the same type).

@zepumph can we discuss this and see if it gets us closer to adopting the no-unused-variables rule?

@zepumph
Copy link
Member Author

zepumph commented May 10, 2022

It was helpful to just refresh myself on const assertions for this https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#const-assertions

@zepumph
Copy link
Member Author

zepumph commented May 10, 2022

I personally really like the approach of having as const as the typing for Multilink and DerivedProperty dependencies. It is definitely a lot of of changing, but it also makes sense that that is how you infer the parameter types for the instance. It should not come from the callback. I'll take a look at the patch and report back.

@zepumph
Copy link
Member Author

zepumph commented May 10, 2022

All the ts-ignores seem to be because Array.map doesn't preserve the tuple-ness of Parameters. I am getting closer, but https://stackoverflow.com/questions/57913193/how-to-use-array-map-with-tuples-in-typescript is really helping me understand. I'll report back.

@zepumph
Copy link
Member Author

zepumph commented May 10, 2022

@samreid
Copy link
Member

samreid commented May 10, 2022

I wanted to chime in quickly that while as const tuples seem natural, we haven't completely ruled out the possibility of using types like Multilink<T1=never, T2=never>, etc (though it wasn't trivial to progress in that direction).

@zepumph
Copy link
Member Author

zepumph commented May 10, 2022

#395 (comment) is making me optimistic that we can potentially do either, since it is mostly about converting between tuples of the same length, but different types. This is what we want to get from MappedProperties to Parameters, using Parameters for the callback and other items in the file.

@zepumph
Copy link
Member Author

zepumph commented May 10, 2022

@samreid, I'm not sure your solution or my further investigation here will work in it's current form. While it successfully knows when you pass it a good callback, it doesn't infer the parameter types of a parameter that doesn't have a type added to the callback:

image

I still believe that the as const method is the way forward, perhaps since it is the way that makes the most sense to me and that I can trust. What do you think about this issue though?

@zepumph zepumph assigned samreid and unassigned zepumph May 10, 2022
@samreid
Copy link
Member

samreid commented May 10, 2022

Yes, in my investigation, it was necessary to specify the types of the callback parameters, like p1: number. While that may be an acceptable cost, I'd like to spend more time on this issue to see if that can be inferred.

@samreid
Copy link
Member

samreid commented May 10, 2022

This patch has the following behavior:

Index: main/axon/js/Multilink.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/main/axon/js/Multilink.ts b/main/axon/js/Multilink.ts
--- a/main/axon/js/Multilink.ts	(revision 13baf1e5dd482848b7c28854a3992fe51706ef01)
+++ b/main/axon/js/Multilink.ts	(date 1652214621975)
@@ -23,17 +23,22 @@
 // constants
 const GET_PROPERTY_VALUE = <T>( property: IReadOnlyProperty<T> ): T => property.get();
 
-const valuesOfProperties = <Parameters extends any[]>( dependencies: MappedProperties<Parameters> ): Parameters => {
+const valuesOfProperties = <Parameters extends readonly any[]>( dependencies: MappedProperties<Parameters> ): Parameters => {
   // Typescript can't figure out that the map gets us back to the Parameters type
-  return dependencies.map( GET_PROPERTY_VALUE ) as Parameters;
+
+  // @ts-ignore
+  return dependencies.map( GET_PROPERTY_VALUE );
 };
 
-// Type of a derivation function, that takes the typed parameters (as a tuple type)
-type Callback<Parameters extends any[]> = ( ...params: Parameters ) => void;
+type Dependencies<T1, T2, T3, T4, T5> = Readonly<[ IReadOnlyProperty<T1> ]> |
+  Readonly<[ IReadOnlyProperty<T1>, IReadOnlyProperty<T2> ]> |
+  Readonly<[ IReadOnlyProperty<T1>, IReadOnlyProperty<T2>, IReadOnlyProperty<T3> ]> |
+  Readonly<[ IReadOnlyProperty<T1>, IReadOnlyProperty<T2>, IReadOnlyProperty<T3>, IReadOnlyProperty<T4> ]> |
+  Readonly<[ IReadOnlyProperty<T1>, IReadOnlyProperty<T2>, IReadOnlyProperty<T3>, IReadOnlyProperty<T4>, IReadOnlyProperty<T5> ]>;
 
-export default class Multilink<Parameters extends any[]> {
+export default class Multilink<T1, T2, T3, T4, T5> {
 
-  private dependencies: MappedProperties<Parameters> | null;
+  private dependencies: Dependencies<T1, T2, T3, T4, T5> | null;
   private dependencyListeners: Map<IReadOnlyProperty<any>, () => void>;
 
   private isDisposed?: boolean;
@@ -43,7 +48,12 @@
    * @param callback function that expects args in the same order as dependencies
    * @param [lazy] Optional parameter that can be set to true if this should be a lazy multilink (no immediate callback)
    */
-  constructor( dependencies: MappedProperties<Parameters>, callback: Callback<Parameters>, lazy?: boolean ) {
+  constructor( dependencies: Readonly<[ IReadOnlyProperty<T1> ]>, callback: ( ...params: [ T1 ] ) => void, lazy?: boolean ) ;
+  constructor( dependencies: Readonly<[ IReadOnlyProperty<T1>, IReadOnlyProperty<T2> ]>, callback: ( ...params: [ T1, T2 ] ) => void, lazy?: boolean ) ;
+  constructor( dependencies: Readonly<[ IReadOnlyProperty<T1>, IReadOnlyProperty<T2>, IReadOnlyProperty<T3> ]>, callback: ( ...params: [ T1, T2, T3 ] ) => void, lazy?: boolean ) ;
+  constructor( dependencies: Readonly<[ IReadOnlyProperty<T1>, IReadOnlyProperty<T2>, IReadOnlyProperty<T3>, IReadOnlyProperty<T4> ]>, callback: ( ...params: [ T1, T2, T3, T4 ] ) => void, lazy?: boolean ) ;
+  constructor( dependencies: Readonly<[ IReadOnlyProperty<T1>, IReadOnlyProperty<T2>, IReadOnlyProperty<T3>, IReadOnlyProperty<T4>, IReadOnlyProperty<T5> ]>, callback: ( ...params: [ T1, T2, T3, T4, T5 ] ) => void, lazy?: boolean ) ;
+  constructor( dependencies: Dependencies<T1, T2, T3, T4, T5>, callback: ( ...params: [ T1, T2, T3, T4, T5 ] ) => void, lazy?: boolean ) {
 
     this.dependencies = dependencies;
 
@@ -59,6 +69,8 @@
 
         // don't call listener if this Multilink has been disposed, see https://github.com/phetsims/axon/issues/192
         if ( !this.isDisposed ) {
+
+          // @ts-ignore
           callback( ...valuesOfProperties( dependencies ) );
         }
       };
@@ -74,6 +86,8 @@
 
     // Send initial call back but only if we are non-lazy
     if ( !lazy ) {
+
+      // @ts-ignore
       callback( ...valuesOfProperties( dependencies ) );
     }
 
@@ -84,7 +98,7 @@
   /**
    * Returns dependencies that are guaranteed to be defined internally.
    */
-  private get definedDependencies(): MappedProperties<Parameters> {
+  private get definedDependencies(): Dependencies<T1, T2, T3, T4, T5> {
     assert && assert( this.dependencies !== null, 'Dependencies should be defined, has this Property been disposed?' );
     return this.dependencies!;
   }
Index: main/ratio-and-proportion/js/discover/view/DiscoverScreenSummaryNode.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/main/ratio-and-proportion/js/discover/view/DiscoverScreenSummaryNode.ts b/main/ratio-and-proportion/js/discover/view/DiscoverScreenSummaryNode.ts
--- a/main/ratio-and-proportion/js/discover/view/DiscoverScreenSummaryNode.ts	(revision 1dfcd906762fa350b98325191ad3d28fe34da75e)
+++ b/main/ratio-and-proportion/js/discover/view/DiscoverScreenSummaryNode.ts	(date 1652214699410)
@@ -19,6 +19,7 @@
 import RatioDescriber from '../../common/view/describers/RatioDescriber.js';
 import IReadOnlyProperty from '../../../../axon/js/IReadOnlyProperty.js';
 import EnumerationProperty from '../../../../axon/js/EnumerationProperty.js';
+import Multilink from '../../../../axon/js/Multilink.js';
 
 type RatioToChallengeNameMap = Map<number, { lowercase: string; capitalized: string }>;
 
@@ -81,13 +82,13 @@
     this.ratioToChallengeNameMap = ratioToChallengeNameMap;
 
     // This derivedProperty is already dependent on all other dependencies for getStateOfSimString
-    Property.multilink( [
+    const x = new Multilink( [
       targetRatioProperty,
       tickMarkViewProperty,
       ratioTupleProperty,
       ratioFitnessProperty,
       inProportionProperty
-    ], ( currentTargetRatio: number, tickMarkView: TickMarkView, currentTuple: RAPRatioTuple, fitness: number, inProportion: boolean ) => {
+    ], ( targetRatio, tickMarkView ) => {
 
       stateOfSimNode.innerContent = this.getStateOfSim();
       leftHandBullet.innerContent = this.getLeftHandState();

No need to specify as const

callback parameter types are inferred correctly

arbitrary number of parameters in the callback

image

Type error if you specify too many callback parameters

image

Multilink type infers correctly, even if you don't specify all the callback parameters:

image

Strategy will work for an arbitrary number of type parameters (currently implemented up to N=5)

Next steps:

  • Update Property.multilink accordingly
  • Apply the same strategy to DerivedProperty.
  • Stale ts-ignores in Multilink (leftover from last patch)

@zepumph
Copy link
Member Author

zepumph commented May 10, 2022

Very fun! Good on ya. I just used this code snippet to note that area model algebra has a multilink with 9 dependencies. I'm still running to get a grand total, but I could totally see a case where some crazy multilink needed to link to 50 Properties. Is that a deal breaker?

Index: js/Multilink.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/js/Multilink.ts b/js/Multilink.ts
--- a/js/Multilink.ts	(revision 13baf1e5dd482848b7c28854a3992fe51706ef01)
+++ b/js/Multilink.ts	(date 1652217482913)
@@ -20,6 +20,7 @@
 import { MappedProperties } from './DerivedProperty.js';
 import IReadOnlyProperty from './IReadOnlyProperty.js';
 
+let bigNum = 0;
 // constants
 const GET_PROPERTY_VALUE = <T>( property: IReadOnlyProperty<T> ): T => property.get();
 
@@ -45,6 +46,11 @@
    */
   constructor( dependencies: MappedProperties<Parameters>, callback: Callback<Parameters>, lazy?: boolean ) {
 
+    if ( dependencies.length > bigNum ) {
+      bigNum = dependencies.length;
+      console.log( bigNum );
+    }
+
     this.dependencies = dependencies;
 
     assert && assert( dependencies.every( _.identity ), 'dependencies should all be truthy' );

@zepumph
Copy link
Member Author

zepumph commented May 10, 2022

Expression Exchange has a multilink with 10 listeners.

@samreid
Copy link
Member

samreid commented May 11, 2022

Maybe we should target 10 and cases that need more can use multiple multilinks, or use a ts-ignore?

@zepumph
Copy link
Member Author

zepumph commented May 11, 2022

I think we can update your patch to use a Tuple type instead of duplicating all those IReadOnlyProperties. I'll work on that hopefully today.

Maybe we should target 10 and cases that need more can use multiple multilinks, or use a ts-ignore?

What about a runtime assertion for that number, and whenever anyone hits it, we just increase the number? And start it at 15 and chances are good that it will be a while before we do hit it.

assert && assert( dependencies.length <15, 'type information is only available for less than 15 dependencies, please update the types for Callback and dependencies to continue' );

@zepumph
Copy link
Member Author

zepumph commented May 12, 2022

I made only minor improvements I believe. I think that we could use this, or perhaps expand it to factor out some of the duplicated code. The internet seem to be in alignment that the way to type a tuple from an array is the intersect it with { length: X } for an X length Tuple.

type MapToPropertiesTuple<TItem extends readonly any[], TLength extends number = TItem['length']> = [ ...MappedProperties<TItem> ] & { length: TLength };

I didn't get to a patch, but I put this into the usages of Dependencies and it worked well.

@samreid
Copy link
Member

samreid commented May 14, 2022

Some extensive changes on the way for Multilink. Summarizing, and the benefits are also described in #395 (comment)

  • Type safety for 15 dependencies.
  • Type inference based on passed-in Properties. I've updated many sims to take out the manually specified type parameters.
  • Type inference on parameters in the callback. I've updated many sims to remove the manually specified parameter types in the callback.
  • Specify as many callback parameters as desired.
  • For storing a Multilink for later unmultilink, please use type UnknownMultilink.
  • When listening to an unknown number of properties, please use Property.multilinkAny. Note this does not give callback values.

Local tests are going well. Fuzz tests OK. Snapshot comparison looks mostly good:

image

Also noting to @jonathanolson that it was easy enough to use different ports for this test.

Next steps will be:

I wrote to slack:

significant changes for Multilink will be committed momentarily. Changes in 83 files across 20 repos. Notes in #395 (comment)

Main usage changes:

  • Do not specify the Multilink generic type parameters, let it infer them.
  • Do not specify the multilink callback parameters, let it infer them.
  • If you have an unknown array of properties to observe, use Property.multilinkAny instead.
    Similar changes for DerivedProperty coming next.

samreid added a commit to phetsims/build-a-nucleus that referenced this issue May 14, 2022
samreid added a commit to phetsims/quadrilateral that referenced this issue May 14, 2022
samreid added a commit to phetsims/wave-interference that referenced this issue May 23, 2022
samreid added a commit to phetsims/plinko-probability that referenced this issue May 23, 2022
samreid added a commit to phetsims/states-of-matter that referenced this issue May 23, 2022
samreid added a commit to phetsims/quadrilateral that referenced this issue May 23, 2022
samreid added a commit to phetsims/models-of-the-hydrogen-atom that referenced this issue May 23, 2022
@samreid
Copy link
Member

samreid commented May 23, 2022

Latest checklist in case it gets hidden by commits: #395 (comment)

UPDATE: I finished the checkboxes assigned to me, over to @zepumph for review and seeing if the types can be simplified without making other sacrifices.

samreid added a commit to phetsims/chipper that referenced this issue May 23, 2022
@samreid samreid removed their assignment May 23, 2022
@jonathanolson
Copy link
Contributor

Fixed some aqua imports above

jonathanolson added a commit to phetsims/fractions-common that referenced this issue May 24, 2022
jonathanolson added a commit to phetsims/masses-and-springs that referenced this issue May 24, 2022
@zepumph
Copy link
Member Author

zepumph commented May 25, 2022

@samreid, I see a couple of changes that may have broken the PhET-iO API. For example phetsims/joist@4128a84. Can you recommend how to proceed? I wasn't sure why we change PhET-iO metadata as part of this issue, but I also don't want to just revert things before discussing with you.

samreid added a commit to phetsims/joist that referenced this issue May 25, 2022
@samreid samreid removed their assignment May 25, 2022
@zepumph
Copy link
Member Author

zepumph commented May 25, 2022

I'm really not sure about any way to improve this. I played around with a couple of changes, but it cascaded to a real headache. Thanks for doing all of this. It is awesome!

@zepumph zepumph assigned samreid and unassigned zepumph May 25, 2022
@samreid
Copy link
Member

samreid commented May 25, 2022

Thanks, closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants