Wasm powered .NET, accessible from TypeScript
The JSImport/JSExport API, the backbone of .NET Webassembly applications, while powerful, lacks type information and exclusively supports static methods. Generally it requires repetitive code patterns to achieve reasonable ergonomics in your code. It takes jΓΊst a little too much effort..
Enter: TypeShim. Drop a [TSExport]/[TSModule] on your C# class and voilΓ , TypeShim generates a set of JSExport methods and TypeScript classes to access your .NET class as if its truely exported to TypeScript.
TypeShim delivers you fully automated construction of your .NET-JS interop by building your C# JSExport facade with a matching TypeScript library to boot.
- Minimal setup: just NuGet install
- One attribute opt-in to interop
- Compile-time generated JSExport shims
- Compile-time generated TypeScript shims
- Enriched type marshalling
- Your classes accessible from TypeScript.
- Code generation ensures Type-safety across the interop boundary.
- Enriched member access:
- Static methods
- Static properties
- Instance methods
- Instance properties
- Analyzers catch type mistakes fast
- No more compile errors after JSExport generates your method
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.
Preservation of type information across the interop boundary, including instance method and property access.
See the TypeShim C# implementation of PeopleRepository and Person
Β
using TypeShim;
namespace Sample.People;
[TsModule]
public static class PeopleModule
{
public static PeopleRepository { get; internal set; } = new PeopleRepository();
}
[TsExport]
public class PeopleRepository
{
internal List<Person> People = [
new Person()
{
Name = "Alice",
Age = 26,
},
new Person()
{
Name = "Bob",
Age = 29,
}
];
public Person GetPerson(int i)
{
return People[i];
}
}
[TsExport]
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
public bool IsOlderThan(Person p)
{
return Age > p.Age;
}
}public UsingTypeShim(exports: AssemblyExports) {
const module = new PeopleModule(exports)
const alice: Person = module.PeopleRepository.GetPerson(0);
const bob: Person = module.PeopleRepository.GetPerson(1);
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
}The exact same behavior as the TypeShim sample, with handwritten JSExport.
See the JSExport C# implementation of PeopleRepository and Person
Β
Note the error sensitivity of passing untyped objects across the interop boundary.
namespace Sample.People;
public class PeopleModule
{
private static readonly PersonRepository _instance = new();
[JSExport]
[return: JSMarshalAsType<JSType.Object>]
public static object GetPeopleRepository()
{
return _instance;
}
}
public class PeopleRepository
{
internal List<Person> People = [
new Person()
{
Name = "Alice",
Age = 26,
},
new Person()
{
Name = "Bob",
Age = 29,
}
];
[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 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;
}
}public UsingRawJSExport(exports: any) {
const repository: any = exports.Sample.People.PeopleModule.GetPeopleRepository();
const alice: any = exports.Sample.People.PeopleRepository.GetPerson(repository, 0);
const bob: any = exports.Sample.People.PeopleRepository.GetPerson(repository, 1);
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
}TypeShim enriches the supported types by JSExport by adding your classes to the types marshalled by .NET. Repetitive patterns for type transformation and higher order types that you'd have to lower into the supported types yourself are readily supported and tested in TypeShim.
Ofcourse, TypeShim brings all types marshalled by .NET to TypeScript. This work is largely completed, but some types are still on the roadmap for support. Support for generics is limited to Task and []. Every supported type can be used in methods as return and parameter types, they are also supported as property types.
TypeShim and JSExport/JSImport are perfectly usable side-by-side, in case you want to handroll parts of your interop.
TypeShim aims to continue to broaden its type support in order to improve the developer experience of .NET Wasm browser apps. Notably Task<int[]> generates compiler error's with JSExport but is within reach to support in TypeShim. Other commonly used types include Enum and IEnumerable.
| TypeShim Shimmed Type | Mapped Type | Support | Note |
|---|---|---|---|
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 |
β | You must process the JSObject manually |
String |
String |
β | |
Object (object) |
Any |
β | |
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]/[TSModule] 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 |
TODO_CONTRIBUTING
Got ideas, found a bug or want more features? Feel free to open a discussion or an issue!