typescript-rtti: Bug: Properties of function type return any

I have a class like this:

class A {
	a: (arg1: string, arg2: boolean) => number;
}

I would like to get the arguments and the return type of such a function-type property. I couldn’t find anything, is there any way to do this?

Thank you!

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Comments: 21 (12 by maintainers)

Commits related to this issue

Most upvoted comments

@Wyctus One of these days i will give it a shot

@Wyctus Support has been added in typescript-rtti@0.7.0.

I really like this library, but this bug forces me to write my own ts transformer plugin, which would require replicating a lot of things that already work perfectly with yours.

That would be a lot of work! Have you considered submitting PRs here instead? I could definitely use some help in moving things forward as we find corner cases and missing functionality.

Since you’ve expressed interest in this feature, I thought you might like to review what it took to add this support, so I did this one as a PR for easy review: https://github.com/typescript-rtti/typescript-rtti/pull/68

To help you or anyone else understand how this library works a bit better, below is an annotated guide through the implementation process. It’s not that tricky, I promise!

To get started, I used the Typescript AST Viewer to analyze how Typescript expressed this particular type: https://ts-ast-viewer.com/#code/JYWwDg9gTgLgBAbzlApgMwDYoMbwL5xpQQhwDkMAnmCgM7ZTBgwC0sMwZA3AFA-YYAhrVpwAgoh5xphCBDgBeOAAoAlIoB8iZChgBXKADs4MKHpRwA-HACMAJgDMcAFzk0c7nDw9v-CIdoILAA6DAgAc2VUTBwYZTFVYPDdAAViGlhKZTJ3CDJEqhpVLiA

I then wrote a quick test in metadata.test.ts with our test snippet and set it to it.only so that I could easily debug into how typescript-rtti was currently handling this type. Support for debugging the test suite is built in to the repository if you are using VS Code.

I then saw we had a bit of a deficiency when handling property types: It would only work for declared types (ie those that have a type annotation on them). I could see this as typeNode was undefined, which makes sense as that will only be present when there is a literal type annotation in the code.

That was fairly easy to fix: image

The MetadataEncoder class has a typeNode() helper which takes a ts.TypeNode and calls type() passing in both the type returned by the type checker for the given node, and the node itself (this is useful for many reasons deeper into the code).

From here, I was no longer getting any back, but instead the expected Function. However, this could be better, as obviously the desire is to reflect upon the function’s return type and parameters. So at this point I headed into format.ts where all of the metadata format types live, so that I could add a new kind of RtRef (RtRefs are the objects which have ie TΦ: '~' indicators). I chose F to represent a function type ref, and constructed a new interface to represent this new kind of RtType

export const T_FUNCTION: 'F' = 'F';

//...

export interface RtFunctionType {
    TΦ: typeof T_FUNCTION;
    r: RtType;
    p: RtParameter[];
    f: string;
}

Here the intent is for r to represent return type, p to represent the set of parameters, and I added f to hold a flags string, though there is so far no use of this (just there for future uses).

To add support for this new RtType, I needed to modify the typeLiteral() function. typeLiteral() is the core of metadata emitting, it is responsible for creating the Typescript AST nodes that represent a specific type (ignoring deduplication / indexing / lookups). A key thing to note is that typeLiteral() is only supposed to construct an AST fragment for the particular type its being called for. If any other types are referenced by that type, it is to use encoder.referToType(type: ts.Type) to do so. This allows the particular type encoder (which can be the legacy emitDecoratorMetadata encoder or the actual RTTI encoder) to handle indexing the types so that a given type is only emitted once for a given source file. The encoder is always passed into typeLiteral():

export function typeLiteral(encoder: TypeEncoderImpl, type: ts.Type, typeNode?: ts.TypeNode, options?: TypeLiteralOptions);

As you can see, the previous behavior here was just to return an identifier token of Function so that the RtType would be the Function constructor. If you examine RtType, you’ll see that it is valid for an RtType to be a constructor function among other things, that is how simple primitive (non-literal) types and classes are represented.

The expansion here simply gets the call signatures for the particular type, warns out if its more than 1, and then proceeds to use the first signature to produce the new RtFunctionType. The serialize() method is a helper to take a literal POJO and turn it into a TS AST fragment that represents that POJO. However, the referToType() returns an AST node already, so the literalNode helper tells serialize() to skip encoding that value and simply pass it back as if it was a ts.Node (as it is).

image

The warning is there to flag to any users that we’re using only the first call signature, as there could be a scenario where that’s possible, but some quick testing using TS AST Viewer showed that using typeof foo where foo is a function with multiple signatures and making an interface with two call signatures yielded a symbol which did not have the Function symbol flag, so I think that warning will never get hit.

A gotcha here is that getTypeOfSymbolAtLocation() really wants a valid node to determine the correct type of a Symbol, but if the typeNode is undefined, how do we do it for the paremeters which is an array of Symbols? The type encoder comes to the rescue here by exposing the RTTI “context” as encoder.ctx. From there, we can access the current “top level statement”, which will be the statement which has the SourceFile as its parent which is currently being processed. This provides a suitable anchor for the type checker to determine the necessary ts.Type to return.

The last part was to add support for this new RtType within the reflection library (reflect.ts). The library uses decorators and some metaprogramming to avoid boilerplate. First, 'function' needed to be added to the ReflectedTypRefKind union, and then an entry was added in the TYPE_REF_KIND_EXPANSION constant. That constant is used to map from the low-level RtType indicators (~, F, |, etc) to the high level “kind” names (any, function, union, etc).

I then added a new ReflectedTypeRef subclass ReflectedFunctionRef annotated with @ReflectedTypeRef.Kind('function'). The decorator tells the library that when a plain ReflectedTypeRef is created which has kind === 'function' it should replace it with an instance of ReflectedFunctionRef. The underlying RtType can be found in the ref protected field. From here, I just added cached getters for the return type and parameters, and implemented the matches() and matchesValue() methods so that the type and value matching functionality works for these new type references.

In order to handle checking that a runtime function value matches the given ReflectedFunctionRef, I augmented the type of matches() to include ReflectedFunction, and then acquired the ReflectedFunction for the passed function, and passed it in:

    protected override matches(ref : this | ReflectedFunction) {
        return this.returnType.equals(ref.returnType)
            && this.parameters.every((p, i) => p.equals(ref.parameters[i]))
            && this.flags.toString() === ref.flags.toString()
        ;
    }

    override matchesValue(value: any, errors?: Error[], context?: string): boolean {
        return this.matches(ReflectedFunction.for(value));
    }

I think that’s pretty nice 😄