Skip to content

Extension methods runtime support and typedefs#212

Merged
jasongin merged 3 commits intomainfrom
dev/jasongin/extension-methods
Feb 26, 2024
Merged

Extension methods runtime support and typedefs#212
jasongin merged 3 commits intomainfrom
dev/jasongin/extension-methods

Conversation

@jasongin
Copy link
Copy Markdown
Member

@jasongin jasongin commented Feb 25, 2024

This change adds runtime and build-time (typedefs) support for calling .NET extension methods from JS.

Previously it was possible, though awkward, to call extension methods as regular static methods. Now, loading an assembly with extension methods dynamically applies the extension methods to existing JS types and instances. This works by defining additional properties on the JS class prototype. And when the corresponding type definitions for the assembly are loaded by the TS compiler, the extension method declarations are merged with the applicable types.

There are some limitations which are inherent in the way .NET types are projected to JS at runtime, and in how .NET type structure can be represented by TS:

  • Extension methods on .NET enums are not supported, because .NET enum values are projected to JS as number constant values.
  • Extension methods on .NET arrays or .NET generic collection types (IEnumerable<>, IList<>, IDictionary<>, etc) are not supported, because those types are projected as JS built-in collection types (Array, Iterable, Set, and Map). While technically it could be possible to modify the prototype of those JS classes, that is not a good practice.
  • Similar to collections, extensions aren't supported for a few other .NET system types that have special mappings to built-in JS types -- for example Task, BigInteger, Memory<T>, Tuple<>.
  • Extension methods on specific constructed generic types cannot be represented in TypeScript -- since generic type parameters are a compile-time facade it's not actually possible to have methods defined for only specific type parameters. For now these are omitted; we can consider including them (applying to any type parameters) later, but that might lead to some conflicts.

Aside from those limitations, most things work, including:

  • Extension methods on a base class apply to (direct or indirect) derived classes.
  • Extension methods on an interface apply to classes that (directly or indirectly) implement the interface.
  • Extension methods on a constructed generic type (MyGenericClass<int>) are supported at runtime (but without typedefs).
  • Generic extension methods on a generic type definition (MyGenericClass<T>) are supported.
  • Overloaded extension methods are supported (with existing limitations of overload resolution), including extension methods that have the same name as non-extension methods.

All of this only works with the "dynamic invocation" scenario, in which assemblies and types are dynamically loaded using reflection and marshalling code is emitted at runtime. Extension methods will NOT be supported for the "dotnet module" scenario (whether using AOT or not), but there is no great need for them there anyway because the module explicitly defines the methods that are exported to JS.

Runtime implementation of extension methods mostly involved refactoring some of the dynamic loading code (previously in ManagedHost) into new classes NamespaceProxy and TypeProxy that keep track of the type relationships (derived types, constructed generics) as needed in order to dynamically apply extension methods to already-exported types whenever new assemblies are loaded.

Type definitions for extension methods became possible after I figured out two things:

  1. TypeScript merges interface members with a declared (ambient, not implemented) class of the same name. (This is not clearly documented: the doc says interfaces merge with interfaces and classes do NOT merge with classes, but nothing about the combination or about declaration-only classes.) Anyway this means we can declare a class and then later (in another .d.ts file) declare an interface that adds extension methods to the class. And of course extension methods on interfaces can use plain interface merging.
  2. Generic factory methods can use the typeof keyword to return a type that represents a generic class's constructor. This avoids the need to split generic classes into a constructor/static interface (previously tagged with an ugly double-dollar) plus an instance interface. And then the same class+interface merging technique can be used for extension methods to generic classes.

The SemanticKernel APIs make heavy use of extension methods, and I updated that example to use extension methods, so the JS code looks a lot cleaner and more like the equivalent C#.

Also as part of this change I improved the way tests and examples use PackageReference to reference the current locally-built version of the packages, using a version.props file generated by the pack.js script. Previously some testing required temporarily updating the csproj files to reference a specific build version.

Fixes: #126

@jasongin jasongin changed the title Extension methods runtime support Extension methods runtime support and typedefs Feb 26, 2024
@jasongin jasongin merged commit f1d8123 into main Feb 26, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Marshalling: Extension methods

2 participants