Skip to content

Repository files navigation

TypeShim

Seamless, type-safe interop between .NET WebAssembly and TypeScript

Test status

Why TypeShim

TypeShim makes interop between .NET WebAssembly and TypeScript effortless. One [TSExport] projects an entire .NET class across the interop boundary, generating a fully-typed mirror in TypeScript. The result is a natural programming experience on both sides: static and instance members, constructors, properties, methods, object instances, reference equality and value types - it all just works.

TypeShim generates strongly-typed interop code for both C# & TypeScript, tailored to your project, so the boundary remains type-safe without manual glue code. Your own [JSExport]s will also be included in the generated TypeScript code. The implementation is verified by a comprehensive test suite covering the full pipeline, from code generation through multiple runtimes, ensuring consistent, reliable behavior. Optimized for minimal build impact, TypeShim achieves sub 100 millisecond codegen times even in large projects.

At a glance

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

Tip

TypeShim pairs great with unplugin-dotnet-wasm for effortless JS bundler configuration and a significantly faster inner dev loop.

Samples

Check out the sample projects to see TypeShim in action. The snippets below also give a general idea of its capabilities.

The snippets compare TypeShim vs manual JSExport. Whichever you use, you'll have load your wasm browser app as described in the docs.

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.

usingTypeShim;namespaceSample.People;[TSExport]publicclassPeopleRepository{internalList<Person>People=[newPerson(){Name="Alice",Age=26,}];publicPersonGetPerson(inti){returnPeople[i];}publicvoidAddPerson(Personp){People.Add(p);}}[TSExport]publicclassPerson{publicstringName{get;set;}publicintAge{get;set;}publicboolIsOlderThan(Personp){returnAge>p.Age;}}

On the TypeScript side things look familiar, class names, properties, methods and constructors all resemble the exported C# classes.

import{dotnet}from'_framework/dotnet'import{PeopleRepository,Person}from'typeshim.ts';publicasyncUsingTypeShim(){awaitdotnet.withApplicationArguments(args).create()constrepository=newPeopleRepository();constalice: Person=repository.GetPerson(0);constbob=newPerson({Name: 'Bob',Age: 20});console.log(alice.Name,bob.Name);// prints "Alice", "Bob"console.log(alice.IsOlderThan(bob))// prints falsealice.Age=30;console.log(alice.IsOlderThan(bob))// prints truerepository.AddPerson({Name: "Charlie",Age: 40});constcharlie: Person=repository.GetPerson(1);console.log(alice.IsOlderThan(charlie))// prints falseconsole.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. The regression sensitivity of such code may also be noted.

See the 'Raw' JSExport implementation
import{dotnet}from'_framework/dotnet'publicasyncUsingRawJSExport(exports: any){construntime=awaitdotnet.withApplicationArguments(args).create();constexports=runtime.assemblyExports;constrepository: any=exports.Sample.People.PeopleRepository.GetInstance();constalice: any=exports.Sample.People.PeopleRepository.GetPerson(repository,0);constbob: 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 falseexports.Sample.People.Person.SetAge(alice,30);console.log(exports.Sample.People.Person.IsOlderThan(alice,bob));// prints trueexports.Sample.People.PeopleRepository.AddPerson(repository,"Charlie",40);constcharlie: any=exports.Sample.People.PeopleRepository.GetPerson(repository,1);console.log(alice.IsOlderThan(charlie))// prints falseconsole.log(bob.IsOlderThan(charlie))// prints true}
namespaceSample.People;publicclassPeopleRepository{internalList<Person>People=[newPerson(){Name="Alice",Age=26,}];privatestaticreadonlyPeopleRepository_instance=new();[JSExport][return:JSMarshalAsType<JSType.Object>]publicstaticobjectGetInstance(){return_instance;}[JSExport][return:JSMarshalAsType<JSType.Object>]publicstaticobjectGetPerson([JSMarshalAsType<JSType.Object>]objectrepository,[JSMarshalAsType<JSType.Number>]inti){PeopleRepositorypr=(PeopleRepository)repository;returnpr.People[i];}}publicclassPerson{publicstringName{get;set;}publicintAge{get;set;}[JSExport][return:JSMarshalAsType<JSType.String>]publicstaticstringConstructPerson([JSMarshalAsType<JSType.Object>]JSObjectobj){returnnewPerson()// Fragile{Name=obj.GetPropertyAsString("Name"),Age=obj.GetPropertyAsInt32("Age")}}[JSExport][return:JSMarshalAsType<JSType.String>]publicstaticstringGetName([JSMarshalAsType<JSType.Object>]objectinstance){Personp=(Person)instance;returnp.Name;}[JSExport][return:JSMarshalAsType<JSType.Void>]publicstaticvoidSetName([JSMarshalAsType<JSType.Object>]objectinstance,[JSMarshalAsType<JSType.String>]stringname){Personp=(Person)instance;p.Name=name;}[JSExport][return:JSMarshalAsType<JSType.Number>]publicstaticintGetAge([JSMarshalAsType<JSType.Object>]objectinstance){Personp=(Person)instance;returnp.Age;}[JSExport][return:JSMarshalAsType<JSType.Void>]publicstaticvoidSetAge([JSMarshalAsType<JSType.Object>]objectinstance,[JSMarshalAsType<JSType.Number>]intage){Personp=(Person)instance;p.Age=age;}[JSExport][return:JSMarshalAsType<JSType.Void>]publicstaticvoidIsOlderThan([JSMarshalAsType<JSType.Object>]objectinstance,[JSMarshalAsType<JSType.Object>]objectother){Personp=(Person)instance;Persono=(Person)other;returnp.Age>o.Age;}}

TypeShim Concepts

Let's 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 the JS runtime. To acquire an instance you may invoke your exported constructor or returned by any method and/or property. 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 generated 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 generated 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. Initializers 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.

constbike=newBike("Ducati",{Cc: 1200,Hp: 147});constrider=newRider({Name: "Casey Stoner",Bike: bike});

Passing an object reference in an initializer object.

constbike: Bike.Initializer={Brand: "Ducati"Cc: 1200,Hp: 147};constrider=newRider({Name: "Pecco",Bike: bike});

Passing an initializer object in another initializer object.

💡 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.

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.

Of course, TypeShim brings all types marshalled by .NET to TypeScript. Makes TypeShim officially offer a superset of the .NET types available in JS.

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

TypeShim Shimmed TypeMapped TypeSupportNote
Object (object)ManagedObjecta disposable opaque handle
TClassManagedObjectunexported reference types
TClassTClassTClass 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*
JSObjectTClassInitializers
TEnumTEnum💡under consideration
IEnumerable<T>T[]💡under consideration
Dictionary<TKey, TValue>?💡under consideration
(T1, T2)[T1, T2]💡under consideration

Table 1. TypeShim supported interop types

.NET Marshalled TypeMapped TypeSupportNote
BooleanBoolean
ByteNumber
CharString
Int16 (short)Number
Int32 (int)Number
Int64 (long)Number
Int64 (long)BigIntArcadeMode/TypeShim#15
Single (float)Number
Double (double)Number
IntPtr (nint)Number
DateTimeDate
DateTimeOffsetDate
ExceptionError
JSObjectObjectRequires manual JSObject handling
StringString
T[]T[]* Only supported .NET types
Span<Byte>MemoryView
Span<Int32>MemoryView
Span<Double>MemoryView
ArraySegment<Byte>MemoryView
ArraySegment<Int32>MemoryView
ArraySegment<Double>MemoryView
TaskPromise* Only supported .NET types
ActionFunction
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

Table 2. TypeShim support for .NET-JS interop types

*For [TSExport] classes

Configuration

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

NameDefaultDescriptionExample / 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_GeneratedDirTypeShimDirectory path (relative to IntermediateOutputPath) for generated YourClass.Interop.g.cs files.TypeShim
TypeShim_MSBuildMessagePriorityNormalMSBuild message priority. Set to High for debugging.Low, Normal, High

Table 3. Configuration options

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 is one reference type that is atypical. It is under consideration to be addressed but an effective alternative is to define your own List class to preserve reference semantics.
  • Classes with generic type parameters can not be part of interop codegen at this time.

Performance

TypeShim has been optimized to achieve average codegen times of ~1 ms per class in a set of benchmarks going up to 200 classes. By optimizing the implementation and providing NativeAOT builds via the NuGet package, most users should see end-to-end codegen times of roughly 50–200 ms for projects with 25–200 classes. Every PR validates both AOT and JIT performance to help maintain these numbers.

Performance is prioritized to minimize build-time impact and deliver the best possible experience for TypeShim users. Secondly it was a good excuse to play around with profiling tools and get some hands on experience with performance optimization and NativeAOT.

The earlier versions of TypeShim used regular JIT builds which suffered expensive runtime start times and an inability to warm-up so even smaller projects would require more than 1 second for codegen. Switching to NativeAOT brought this down to the quarterisecond range and after several optimizations has been reduced to below a tenth of a second in many cases.

Results from the continuous benchmarking that is now part of every pull request are shown in Table 4. The 0 classes case demonstrates the overhead of starting the process without doing any work.

MethodCompilationClassCountMeanErrorStdDev
GenerateAOT014.02 ms1.319 ms0.873 ms
GenerateAOT131.35 ms0.969 ms0.641 ms
GenerateAOT1031.82 ms1.683 ms1.113 ms
GenerateAOT2545.32 ms1.565 ms1.035 ms
GenerateAOT5056.50 ms1.103 ms0.730 ms
GenerateAOT10091.60 ms2.294 ms1.517 ms
GenerateAOT20093.92 ms1.553 ms1.027 ms
GenerateJIT042.07 ms0.687 ms0.454 ms
GenerateJIT1813.62 ms10.321 ms6.827 ms
GenerateJIT10814.93 ms9.107 ms6.024 ms
GenerateJIT25862.08 ms11.549 ms7.639 ms
GenerateJIT50900.00 ms14.144 ms9.355 ms
GenerateJIT1001,014.10 ms12.046 ms7.968 ms
GenerateJIT200986.96 ms22.021 ms14.565 ms

Table 4. Benchmark results on an AMD EPYC 7763 2.45GHz Github Actions runner.

Contributing

Contributions are welcome.

  • Please discuss proposals in an issue before submitting changes.
  • Bugfixes should come with at least one test demonstrating the issue and its resolution.
  • New features should come with unit- and E2E tests to demonstrate their correctness.
  • PRs should be made from a fork.

Got ideas, found a bug or have an idea for a new feature? Feel free to open a discussion or an issue!

About

Seamless, type-safe interop between .NET WebAssembly and TypeScript

Topics

Resources

Stars

17 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages