typescriptfluenttypescript-decorator

TS decorator typing: How to backpropagate decorated property type through fluent API in TypeScript?


I am trying to build a decorator library that uses a fluent API. Some of these API members should take a closure that should be typed according to the decorated member or its containing class.

I have already figured out that TypeScript can infer the decorated type using the ClassFieldDecoratorContext - but only for a simple decorator with one invocation.

To demonstrate the problem I have created a simple dummy case:

//This decorator works
function LogSubMember<C, M>(accessor: (member: M, clss: C) => void) : (target: undefined, context: ClassFieldDecoratorContext<C, M>) => void {
    return () => {};
}

//I struggle with this type
type FluentDecoratorFactory = {
    SubMember<C, M>(accessor: (member: M, clss: C) => void) : { (target: undefined, context: ClassFieldDecoratorContext<C, M>): void } & FluentDecoratorFactory,
    To<C, M>(target: string): { (target: undefined, context: ClassFieldDecoratorContext<C, M>): void } & FluentDecoratorFactory
}

let Log = new Proxy({}, {}) as FluentDecoratorFactory;

class Example {
    //This works
    @LogSubMember(m => m.blah)
    private foo: { blah: number; blurb: number };

    //Here m is of type unknown
    @Log.SubMember(m => m.hour).To("file")
    private bar: { hour: number; minute: number };
}

The problem I have identified is that I kind of need to backpropagate the typings of the last member of the access chain all the way up. I tried making FluentDecoratorFactory generic over <C,M> but then I run into the problem of locking the types in at definition time, and not at usage time.

So in consequence I tried to split the type into two parts, but to no avail:

type FluentDecoratorFactory = {
    SubMember<C, M>(accessor: (member: M, clss: C) => void) : { (target: undefined, context: ClassFieldDecoratorContext<C, M>): void } & ModifierChain<C,M>
}

type ModifierChain<C,M> =
{
    To(target: string): { (target: undefined, context: ClassFieldDecoratorContext<C, M>): void } & ModifierChain<C, M>
}

I am aware an easy alternative would of course be to enclose the fluent API into the closure itself as in @Log((member,clss) => member.hour.to("file")) but this is not my preferred solution.

Any hints? For convenience you can find the problem in the Playground: TS Playground

Edit: After some more investigating I am at a loss of understanding how inference really works.

Here is what else I tried:

type FluentDecoratorFactory<C, M> = {
    (target: any, context: ClassFieldDecoratorContext<C, M>): void;
    SubMember<CInferred, MInferred>(accessor: (member: MInferred, clss: CInferred) => void): FluentDecoratorFactory<CInferred, MInferred>;
};

type ChainedFactory = {
    SubMember<C, M>(accessor: (member: M, clss: C) => void): ChainedFactoryA<C, M>;
};

type ChainedFactoryA<C,M> = {
    (target: any, context: ClassFieldDecoratorContext<C, M>): void;
    SubMember(accessor: (member: M, clss: C) => void): ChainedFactoryB<C, M>;
}

type ChainedFactoryB<C,M> = {
    (target: any, context: ClassFieldDecoratorContext<C, M>): void;
}

let Log = new Proxy({}, {}) as FluentDecoratorFactory<unknown, unknown>;
let ChainedLog = new Proxy({}, {}) as ChainedFactory;

class Example
{
    //This works
    @Log.SubMember(m => m.hour)
    //This breaks
    @(Log.SubMember(m => m.hour).SubMember(m => m.minute))

    //This works
    @ChainedLog.SubMember(m => m.hour)
    //This breaks even worse then pure Log
    @(ChainedLog.SubMember(m => m.hour).SubMember(m => m.minute))
    private bar!: { hour: number; minute: number; };
}

TS Playground

I fail to generalize a rule from these experiments, but I guess I must assume that "backward" inference is only happening for function generics and only across one layer of type generic inference...


Solution

  • (Note: this answer deals with ES Decorators not TS experimental Decorators)

    TypeScript lacks the ability to propagate inferences backwards through multiple function calls. TypeScript has been able to infer from expected return type for a single function call since TypeScript 2.4, but there is no ability to chain functions and pass such inference backward along the chain. A feature request for similar functionality is at microsoft/TypeScript#47440, but it doesn't have much community engagement, so I doubt this is something TypeScript will be able to do.

    Decorators rely on this inference from the return type, so that means you can get contextual typing for the callback parameter of one function call, but chains of function calls will fail to have such typing for all but the last element of the chain.

    So the answer to the question as asked is: "this isn't possible, sorry".


    That means you either need to completely give up, or ask people to manually annotate their callback parameters, or refactor so that the decorator is a single function call and not a chain.

    There are lots of possible refactorings that would work, and it's technically out of scope for this question to go through them. But I'll describe one possible approach here: wrapping your chain in a single outer function call, changing calls like @Log.⋯ to @Log(fact => fact.⋯):

    type Ret<C, M> = {
        (target: undefined, context: ClassFieldDecoratorContext<C, M>): void
    } & FluentDecoratorFactory<C, M>;
    
    type OuterFluentDecoratorFactory = <C, M>(
        cb: (inner: FluentDecoratorFactory<C, M>) => FluentDecoratorFactory<C, M>
    ) => Ret<C, M>;
    
    type FluentDecoratorFactory<C, M> = {
        SubMember(accessor: (member: M, clss: C) => void): Ret<C, M>
        To(target: string): Ret<C, M>
    }
    
    let Log = null! as OuterFluentDecoratorFactory;
    
    class Example {
        @Log(fact => fact.SubMember(m => m.blah))
        private foo!: { blah: number; blurb: number };
    
        @Log(fact => fact.SubMember(m => m.hour).To("file"))
        private bar!: { hour: number; minute: number };
    } 
    

    This works because Log is an OuterFluentDecoratorFactory, a single function that can therefore get the callback parameter type for FluentDecoratorFactory<C, M> inferred from the C and M of Ret<C, M>, which ultimately comes from the ClassFieldDecoratorContext<C, M> from the field/class being decorated (where ClassFieldDecoratorContext was as introduced in microsoft/TypeScript#50820, the pull request implementing ES Decorators).

    Once this happens, the chain of functions inside the @Log(fact => fact.⋯) call is well-typed because C and M are known and constant (you can see that the scope of the generic type parameters in FluentDecoratorFactory has moved from the methods to the type itself). So there's no longer any backwards inference needed. The types flow from "left to right" which is much easier for TypeScript to handle.

    Playground link to code