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

Types for Decorators Design Sync, 10/28/2022 #51347

Closed
DanielRosenwasser opened this issue Oct 28, 2022 · 5 comments
Closed

Types for Decorators Design Sync, 10/28/2022 #51347

DanielRosenwasser opened this issue Oct 28, 2022 · 5 comments
Labels
Design Notes Notes from our design meetings

Comments

@DanielRosenwasser
Copy link
Member

This wasn't a formal design meeting, but I figured it was worth taking notes for it.

Type Mutation for Decorators

Best Current Decorators Overview I Know Of: https://2ality.com/2022/10/javascript-decorators.html

Current Decorators Issue: #48885
Ron’s Decorators PR: #50820
Original “Decorator Mutation” Issue: #4881

  • Want usages of a property to be a string.

    declare function Stringify(target: undefined, context: ClassFieldDecoratorContext): (value: string | number) => string;
    
    class C {
        @Stringify x: number = 1;
    
        constructor() {
            this.x; // usage is a 'string'
        }
    
        method() {
            this.x // usage is a 'string'
        }
    }
    • Potential for confusion - but we believe that people can "get it" either way after the initial learning hump.
    • But declaration emit - sometimes our declaration emitter requires users to specify something explicitly to get things working.
    • Does the annotation refer to the pre-decorated type, or the post-decorated type?
    • If we use the annotation as the final type, you can use it as the contextual type of the decorator call in some case.
    • Arguments for resulting type?
    • Arguments for "current type"?
      • Short circuiting circularities with type annotations is useful - type assertions still resolve the expression.
      • Still may need to refer to intermediate types.
    • What does typeof C refer to when C has any number of decorations?
  • Seems reasonable for fields; what about signatures?

    • Could declare overloads?

      class C {
          method(x: Boxed<string>): void;
          @BoxFirstArgument
          method(s: string) {
            this.x;
          }
      }
    • But so if method is invoked somewhere else...then the signature is different?

      • Maybe.
    • So when do you need a separate method signature?

    • Just weird that you could write

      @BoxFirstArgument
      func: (s: Boxed<sring>) => void = (s: string) => {};
      • Well. Yes.
  • Seems like annotation should reflect the final type?

  • What about a future with parameter decorators?

    • Need to be able to specify the type for callers, not the body of the function.

      class C {
        constructor(@Stringify foo: number) {
            foo; // this is a string
      
            foo.toUpperCase(); // valid
        }
      }
      
      new C(123); // works
      new C("str"); // error
      • Callers need to call with a number, body needs to witness a string.
  • Type mutations can be observed from several places in decorator invocations (as they capture the original target types). Modeled via type parameters.

    • Input - type of the declaration.
    • Output - type given the current declaration.
    • Final - final type of declaration
    • This - final type of the constructor
  • There is a reason on top of ES standardization that we've avoided just adding the type modification behavior of decorators for years - all of this is entirely subtle.

  • A big part of the subtlety is that the decorator affects the containing class.

  • Can we avoid witnessing the entire evolution of the class?

    • Each decorator may expect the output of the previously-running decorator.
  • The decorator can replace the class entirely with a function.

  • Could pre-allocate unresolved types on every decorator invocation so that we can defer and force resolution only when necessary.

  • Would it help to be able to say decorators can fully change members, but the final resulting class must be a subtype?

    • Not exactly - within the class, you might want to take advantage of introduced methods.
    • Kind of like the "class mixing factory".
  • What are the invariants in the compiler?

    • Single symbol for a single entity.

      • A symbol's resolution can be deferred, and we don't know what the order of resolution will be.
      • Symbol types don't "evolve" - they are either resolved, resolving (temporary state to detect circularities), or complete.
    • Reasonable way to model this, but there needs to be a new symbol "kind" that doesn't know what kind of declaration it's resolving to.

      • Kind of sounds like a special version of anonymous object types. Are they?
    • So this would look like:

      // Order is specified by letter, then number in ascending order.
      
      @E2 @E1 class SomeClass {
        // decorator applied symbols
        // final "x" -> @C2 -> @C1 -> original "x"
        @C2 @C1 static x;
        @A2 @A1 static F() {}
      
        @D2 @D1 y;
        @B2 @B1 g() {}
      }
    • There is also addInitializer

      • Should not be able to apply mutations, but can witness the final type.
      • The addInitializer of A1 may require the type produced by @E2.
    • So final SomeClass = @E2 -> @E1 -> {@A2, @B2, @C2, D2}

  • Aside: is SomeClass TDZ?

    • Method decorators can be functions and refer to the final incomplete state.

      @Blah
      class C {
        @((t, c) => {
            // valid but questionable.
            SomeClass.x
      
            c.addInitializer(() => {
                // valid.
                SomeClass.x
            })
        })
        static x;
      }
  • Seems sound, but this seems to fall over in language service scenarios.

    • Circularities can be triggered before type-checking by things like auto-completion or quick info.
  • Back to earlier topic - sometimes you want the annotation to be the original type so that you can infer for the decorator input.

    • If the annotation is the final type, then the decorators can take an explicit type argument, or do return-type inference (which is an anti-pattern but 🤷🏻‍♂️).
    • Parameters might need to be the output position too
      • Seems strange.
  • Feel like we're converging on ideas, but not 100% yet. Need more discussion.

  • The first thing we ship will likely be a version where we forbid type mutations - maybe things being a strict subtype.

@DanielRosenwasser DanielRosenwasser added the Design Notes Notes from our design meetings label Oct 28, 2022
@DanielRosenwasser
Copy link
Member Author

How is the desire to enable "standalone .d.ts emit" not at odds with decorators replacing the entire class with a different type?

Perhaps

@addFooProperty
class C {
}

would need to be rewritten as

@addFooProperty
class C {
    declare foo: any;
}

@chriskrycho
Copy link

Thank you for posting these notes—the subtleties are not few!

The first thing we ship will likely be a version where we forbid type mutations - maybe things being a strict subtype.

One thing I've wondered about as an initial minimal pass—for ORMs, DI, etc., it’s common to have patterns that look like these (I’m using code here from Ember and Ember Data services, but the same patterns show up in other contexts):

class SomeModel {
  @attr('string') name;
  @attr('number') age;

  @service session;
}

Notably, if you wrote exactly this code the produced type is just any (and so forbidden with noImplicitAny). That would fit nicely under the "strict subtype" approach, and I think it would solve a large swath of at least one common subset of decorators in wide use.

I think the "strict subtype" rule would also allow for narrowing? That one seems a bit stranger, to be honest:

class Example {
  @nonNull foo: string | null;
}

@Jack-Works
Copy link
Contributor

What about this? Let the converted type be declared on the decorator instead of the class field.

declare function Await<T extends PromiseLike>(target: undefined, context: ClassFieldDecoratorContext): (value: T) => Awaited<T>;
declare function NonNull<T>(target: undefined, context: ClassFieldDecoratorContext): (value: T | undefined | null) => T;
declare function DependencyInject<T extends Injectable>(): (target: undefined, context: ClassFieldDecoratorContext) => (value: T) => T;

class T {
    @Await field
    //     ~~~~~ implicit any
    @Await field: number
    // contextual type to infer to Await<Promise<number>>?
    @Await<Promise<number>> field
    //                      ~~~~~ number
    @Await<Promise<number>> field: string
    //     ~~~~~~~~~~~~~~~       ~~~~~~~~ incompatible declaration

    @NonNull<string | null> field
    //                      ~~~~~ string
    @Service(MyClass) field
    //                ~~~~~ MyClass instance
}

@DanielRosenwasser
Copy link
Member Author

Yup, leaning on type arguments and inference from the annotation type as a contextual type to the return type is discussed a bit in the notes and that's the current way we're leaning - specifically:

If we use the annotation as the final type, you can use it as the contextual type of the decorator call in some case.

@fatcerberus
Copy link

Image macro of duck saying "WAT"

Time for me to retire now and become a duck...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Design Notes Notes from our design meetings
Projects
None yet
Development

No branches or pull requests

5 participants