Skip to content

ArcadeMode/TypeShim

Repository files navigation

TypeShim

Strongly-typed .NET-JS interop facade generation

Why TypeShim

The JSImport/JSExport API, the backbone of .NET Webassembly applications, while powerful, is somewhat cumbersome to use. It requires repetitive code patterns and quite some boilerplate to use. The lack of class support mean that many method annotations have to be written for a lot of use cases. Finally there is no type information available to use in your TypeScript project.

Enter: TypeShim. Drop one [TSExport] on your C# class(es) and voilΓ , TypeShim generates the necessary JSExport methods that perform repetitive type transformations and even class instantiations for you. The void that JSExport leaves on the JS side is filled with a rich TypeScript client that enables you to use your .NET classes as if they were truly exported to TypeScript.

Features at a glance

  • 🏭 No-nonsense interop generation.
  • 🌱 Opt-in with just one attribute.
  • πŸ€– Export full classes: constructors, methods and properties.
  • πŸ’° Enriched type marshalling.
  • πŸ›‘ Type-safety across the interop boundary.
  • πŸ‘ Easy setup
  • 🧩 Compatible with your other JSExport/JSImport.

Samples

Samples below demonstrate the same operations when interfacing with TypeShim generated code vs JSExport generated code. Either way you will load your wasm browserapp as described in the docs in order to retrieve its exports.

TypeShim

A simple example where we have an app about 'people', just to show basic language use powered by TypeShim. The C# implementation is just classes with the mentioned [TSExport] annotation.

using TypeShim;

namespace Sample.People;

[TsExport]
public class PeopleRepository
{
    internal List<Person> People = [
        new Person()
        {
            Name = "Alice",
            Age = 26,
        }
    ];

    public Person GetPerson(int i)
    {
        return People[i];
    }

    public void AddPerson(Person p)
    {
        People.Add(p);
    }
}

[TsExport]
public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    
    public bool IsOlderThan(Person p)
    {
        return Age > p.Age;
    }
}

And the TypeScript side can look like this. One extra type is the TypeShimInitializer which should be passed the created runtime before engaging with interop. This is so helper functions for type marshalling can be set up a reference to the assembly exports can be retrieved.

using { TypeShimInitializer, PeopleRepository, Person } from './typeshim.ts';

public UsingTypeShim() {
    const runtime = await dotnet.withApplicationArguments(args).create()
    TypeShimInitializer.initialize(runtime);

    const repository = new PeopleRepository();
    const alice: Person = repository.GetPerson(0);
    const bob = new Person({
      Name: 'Bob',
      Age: 20
    });

    console.log(alice.Name, bob.Name); // prints "Alice", "Bob"
    console.log(alice.IsOlderThan(bob)) // prints false
    alice.Age = 30;
    console.log(alice.IsOlderThan(bob)) // prints true

    repository.AddPerson({ Name: "Charlie", Age: 40 });
    const charlie: Person = repository.GetPerson(1);
    console.log(alice.IsOlderThan(charlie)) // prints false
    console.log(bob.IsOlderThan(charlie)) // prints true
}

'Raw' JSExport

Here you can see a quick demonstration of roughly the same behavior as the TypeShim sample, with handwritten JSExport. Certain parts enabled by TypeShim have not been replicated as the point may be clear at a glance: this is a large amount of difficult to maintain boilerplate if you have to write it yourself.

See the 'Raw' JSExport implementation Β 

Note the error sensitivity of passing untyped objects across the interop boundary.

public UsingRawJSExport(exports: any) {
    const runtime = await dotnet.withApplicationArguments(args).create();
    const exports = runtime.assemblyExports;

    const repository: any = exports.Sample.People.PeopleRepository.GetInstance(); 
    const alice: any = exports.Sample.People.PeopleRepository.GetPerson(repository, 0);
    const bob: any = exports.Sample.People.People.ConstructPerson("Bob", 20);
    
    console.log(exports.Sample.People.Person.GetName(alice), exports.Sample.People.Person.GetName(bob)); // prints "Alice", "Bob"
    console.log(exports.Sample.People.Person.IsOlderThan(alice, bob)); // prints false
    exports.Sample.People.Person.SetAge(alice, 30);
    console.log(exports.Sample.People.Person.IsOlderThan(alice, bob)); // prints true

    exports.Sample.People.PeopleRepository.AddPerson(repository, "Charlie", 40);
    const charlie: any = exports.Sample.People.PeopleRepository.GetPerson(repository, 1);
    console.log(alice.IsOlderThan(charlie)) // prints false
    console.log(bob.IsOlderThan(charlie)) // prints true
}
namespace Sample.People;

public class PeopleRepository
{
    internal List<Person> People = [
        new Person()
        {
            Name = "Alice",
            Age = 26,
        }
    ];

    private static readonly PersonRepository _instance = new();
    [JSExport]
    [return: JSMarshalAsType<JSType.Object>]
    public static object GetInstance()
    {
        return _instance;
    }

    [JSExport]
    [return: JSMarshalAsType<JSType.Object>]
    public static object GetPerson([JSMarshalAsType<JSType.Object>] object repository, [JSMarshalAsType<JSType.Number>] int i)
    {
        PersonRepository pr = (PersonRepository)repository;
        return pr.People[i];
    }
}

public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    
    [JSExport]
    [return: JSMarshalAsType<JSType.String>]
    public static string ConstructPerson([JSMarshalAsType<JSType.Object>] JSObject obj)
    {
        return new Person() // Fragile
        {
            Name = obj.GetPropertyAsString("Name"),
            Age = obj.GetPropertyAsInt32("Age")
        }
    }

    [JSExport]
    [return: JSMarshalAsType<JSType.String>]
    public static string GetName([JSMarshalAsType<JSType.Object>] object instance)
    {
        Person p = (Person)instance;
        return p.Name;
    }

    [JSExport]
    [return: JSMarshalAsType<JSType.Void>]
    public static void SetName([JSMarshalAsType<JSType.Object>] object instance, [JSMarshalAsType<JSType.String>] string name)
    {
        Person p = (Person)instance;
        return p.Name = name;
    }

    [JSExport]
    [return: JSMarshalAsType<JSType.Number>]
    public static int GetAge([JSMarshalAsType<JSType.Object>] object instance)
    {
        Person p = (Person)instance;
        return p.Age;
    }

    [JSExport]
    [return: JSMarshalAsType<JSType.Void>]
    public static void SetAge([JSMarshalAsType<JSType.Object>] object instance, [JSMarshalAsType<JSType.Number>] int age)
    {
        Person p = (Person)instance;
        return p.Age = age;
    }

    [JSExport]
    [return: JSMarshalAsType<JSType.Void>]
    public static void IsOlderThan([JSMarshalAsType<JSType.Object>] object instance, [JSMarshalAsType<JSType.Object>] object other)
    {
        Person p = (Person)instance;
        Person o = (Person)other;
        return p.Age > o.Age;
    }
}

TypeShim Concepts

Lets briefly introduce the concepts that are used in TypeShim. For starters, you will be using [TSExport] to annotate your classes to define your interop API. Every annotated class will receive a TypeScript counterpart. The members included in the TypeScript code are limited to the public members. That includes constructors, properties and methods, both static and instance.

The build-time generated TypeScript can provide the following subcomponents for each exported class MyClass:

Proxies (MyClass)

MyClass grants access to the exported C# MyClass class in a proxying capacity, this type will also be referred to as a Proxy. A dotnet instance of the class being proxied always lives in the dotnet runtime when you receive a proxy instance, changes to the dotnet object will reflect in typescript. To aquire an instance you may invoke your exported constructor or returned by any method and/or property. Alternatively you can access static members all the same. Proxies may also be used as parameters and will behave as typical reference types when performing any such operation.

Snapshots (MyClass.Snapshot)

The snapshot type is created if your class has public properties. TypeShim provides a utility function MyClass.materialize(your_instance) that returns a snapshot. Snapshots are fully decoupled from the dotnet object and live in the JS runtime, this means that changes to the proxy object do not reflect in a snapshot. Properties of proxy types will be materialized as well. This is useful when you no longer require the Proxy instance but want to continue working with its data.

Initializers (MyClass.Initializer)

The Initializer type is created if the exported class has an exported constructor and accepts an initializer body in new() expressions. Initializer objects live in the JS runtime and may be used in the process of creating dotnet object instances, if it exists it will be a parameter in the constructor of the associated Proxy.

Additionally, if the class exports a parameterless constructor then initializer objects can also be passed instead of proxies in method parameters, property setters and even in other initializer objects. TypeShim will construct the appropriate dotnet class instance(s) from the initializer. Initializer's can even contain properties of Proxy type instead of an Initializer if you want to reference an existing object. Below a brief demonstration of the provided flexibility.

πŸ’‘ Arrays of mixed proxies and initializers are supported as parameters for methods if the above conditions for the array element type are satisfied. The contained initializer objects will be constructed into new dotnet class instances while the object references behind the proxies are preserved.

const bike = new Bike("Ducati", { 
  Cc: 1200,
  Hp: 147
});
const rider = new Rider({
    Name: "Casey Stoner",
    Bike: bike
});

Passing an object reference in an initializer object.

const bike: Bike.Initializer = {
  Brand: "Ducati" 
  Cc: 1200,
  Hp: 147
};
const rider = new Rider({
    Name: "Pecco",
    Bike: bike
});

Passing an initializer object in another initializer object.

Enriched Type support

TypeShim enriches the supported types by JSExport by adding your classes to the types marshalled by .NET. Repetitive patterns for type transformation are readily supported and tested in TypeShim.

Ofcourse, TypeShim brings all types marshalled by .NET to TypeScript. This work is largely completed. Support for generics is limited to Task and [].

TypeShim aims to continue to broaden its type support. Suggestions and contributions are welcome.

TypeShim Shimmed Type Mapped Type Support Note
Object (object) ManagedObject βœ… a disposable opaque handle
TClass ManagedObject βœ… unexported reference types
TClass TClass βœ… TClass generated in TypeScript*
Task<TClass> Promise<TClass> βœ… TClass generated in TypeScript*
Task<T[]> Promise<T[]> πŸ’‘ under consideration (for all array-compatible T)
TClass[] TClass[] βœ… TClass generated in TypeScript*
JSObject TClass πŸ’‘ ArcadeMode/TypeShim#4 (TS β†’ C# only)
TEnum TEnum πŸ’‘ under consideration
IEnumerable<T> T[] πŸ’‘ under consideration
Dictionary<TKey, TValue> ? πŸ’‘ under consideration
(T1, T2) [T1, T2] πŸ’‘ under consideration
.NET Marshalled Type Mapped Type Support Note
Boolean Boolean βœ…
Byte Number βœ…
Char String βœ…
Int16 (short) Number βœ…
Int32 (int) Number βœ…
Int64 (long) Number βœ…
Int64 (long) BigInt ⏳ ArcadeMode/TypeShim#15
Single (float) Number βœ…
Double (double) Number βœ…
IntPtr (nint) Number βœ…
DateTime Date βœ…
DateTimeOffset Date βœ…
Exception Error βœ…
JSObject Object βœ… Requires manual JSObject handling
String String βœ…
T[] T[] βœ… * Only supported .NET types
Span<Byte> MemoryView 🚧
Span<Int32> MemoryView 🚧
Span<Double> MemoryView 🚧
ArraySegment<Byte> MemoryView 🚧
ArraySegment<Int32> MemoryView 🚧
ArraySegment<Double> MemoryView 🚧
Task Promise βœ… * Only supported .NET types
Action Function 🚧
Action<T1> Function 🚧
Action<T1, T2> Function 🚧
Action<T1, T2, T3> Function 🚧
Func<TResult> Function 🚧
Func<T1, TResult> Function 🚧
Func<T1, T2, TResult> Function 🚧
Func<T1, T2, T3, TResult> Function 🚧

*For [TSExport] classes

Run the sample

To build and run the project:

cd Sample/TypeShim.Sample.Client && npm install && npm run build && cd ../TypeShim.Sample.Server && dotnet run

The app should be available on http://localhost:5012

Installing

To use TypeShim all you have to do is install it directly into your Microsoft.NET.Sdk.WebAssembly-powered project. Check the configuration section for configuration you might want to adjust to your project.

nuget install TypeShim

Configuration

TypeShim is configured through MSBuild properties, you may provide these through your .csproj file or from the msbuild/dotnet cli.

Name Default Description Example / Options
TypeShim_TypeScriptOutputDirectory "wwwroot" Directory path (relative to OutDir) where typeshim.ts is generated. Supports relative paths. ../../myfrontend
TypeShim_TypeScriptOutputFileName "typeshim.ts" Filename of the generated TypeShim TypeScript code. typeshim.ts
TypeShim_GeneratedDir TypeShim Directory path (relative to IntermediateOutputPath) for generated YourClass.Interop.g.cs files. TypeShim
TypeShim_MSBuildMessagePriority Normal MSBuild message priority. Set to High for debugging. Low, Normal, High

Limitations

TSExports are subject to minimal, but some, constraints.

  • Certain types are not supported by either TypeShim or .NET wasm type marshalling. Analyzers have been implemented to notify of such cases.
  • As overloading is not a real language feature in JavaScript nor TypeScript, this is currently not supported in TypeShim either. You can still define overloads that are not public. This goes for both constructors and methods.
  • By default, JSExport yields value semantics for Array instances, this one reference type that is atypical. It is under consideration to adres but an effective alternative is to define your own List class to preserve reference semantics.

Contributing

TODO_CONTRIBUTING


Got ideas, found a bug or want more features? Feel free to open a discussion or an issue!