Skip to content

ArcadeMode/TypeShim

Repository files navigation

TypeShim - Typesafe .NET β†”οΈŽ TypeScript interop

Wasm powered .NET, accessible from TypeScript

Why TypeShim

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.

Features at a glance

  • 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

Feature: instance member access from TypeScript

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

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
}

'Raw' JSExport

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
}

Feature: 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 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

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

Contributing

TODO_CONTRIBUTING


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