Strongly-typed .NET-JS interop facade generation
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.
- π 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 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.
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
}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;
}
}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:
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.
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.
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. |
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
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
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
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 |
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.
TODO_CONTRIBUTING
Got ideas, found a bug or want more features? Feel free to open a discussion or an issue!