This guide covers using existing .NET types directly from TypeScript code. This enables you to leverage the full .NET Base Class Library (BCL) and third-party .NET libraries from your TypeScript programs.
SharpTS supports two forms of .NET interop:
| Direction | Description | Use Case |
|---|---|---|
| Outbound | Compile TypeScript to .NET DLLs | Consume TS libraries from C# |
| Inbound | Use .NET types from TypeScript | Access BCL and .NET libraries |
This guide covers inbound interop - calling .NET code from TypeScript using the @DotNetType decorator.
- .NET 10.0 SDK
- Decorators enabled (default, or pass
--experimentalDecoratorsfor Legacy mode)
@DotNetType works in both execution modes:
| Mode | Notes |
|---|---|
Compiled (--compile) |
Overload resolution runs at compile time using TypeScript static types. IL-level Callvirt/Newobj directly invoke the resolved method. |
| Interpreted (default) | Overload resolution runs per call against the actual runtime argument types. Resolved MethodInfos are cached on the wrapper so repeated calls avoid reflection lookup. |
Use @DotNetOverload(...) when runtime resolution can't pick the overload you want — see Overload Hints.
Note: Decorators are enabled by default (TC39 Stage 3). Use
--experimentalDecoratorsfor Legacy (Stage 2) decorators, or--noDecoratorsto disable decorator support.
// Declare an external .NET type
@DotNetType("System.Text.StringBuilder")
declare class StringBuilder {
constructor();
append(value: string): StringBuilder;
toString(): string;
}
// Use it like a native TypeScript class
let sb = new StringBuilder();
sb.append("Hello, ");
sb.append("World!");
console.log(sb.toString()); // Output: Hello, World!Compile and run:
sharpts --compile example.ts
dotnet example.dllThe @DotNetType decorator binds a TypeScript class declaration to an existing .NET type:
@DotNetType("Fully.Qualified.TypeName")
declare class TypeScriptName {
// Method and property signatures
}- First argument: The fully-qualified .NET type name (e.g.,
System.Text.StringBuilder) declare class: Indicates this is an external type with no implementation in TypeScript
Use declare class to define the TypeScript interface for a .NET type. You only need to declare the members you intend to use:
@DotNetType("System.Guid")
declare class Guid {
static newGuid(): Guid;
static parse(input: string): Guid;
toString(): string;
}
// You don't need to declare every method - just what you use
let id = Guid.newGuid();
console.log(id.toString());| Member Type | TypeScript Syntax | Example |
|---|---|---|
| Constructor | constructor(params) |
constructor(capacity: number) |
| Instance method | methodName(params): ReturnType |
append(value: string): StringBuilder |
| Static method | static methodName(params): ReturnType |
static newGuid(): Guid |
| Instance property | propertyName: Type |
length: number |
| Readonly property | readonly propertyName: Type |
readonly length: number |
| Static property | static propertyName: Type |
static readonly now: DateTime |
When calling .NET methods, SharpTS automatically converts TypeScript types:
| TypeScript Type | .NET Type | Notes |
|---|---|---|
number |
double |
Default numeric mapping |
number |
int, long, float, byte |
Narrowing conversion when method expects it |
string |
string |
Direct mapping |
boolean |
bool |
Direct mapping |
object |
object |
Dynamic fallback |
TypeScript uses camelCase while .NET uses PascalCase. SharpTS handles this automatically:
| .NET Method | TypeScript Declaration |
|---|---|
Append() |
append() |
GetValue() |
getValue() |
ToString() |
toString() |
NewGuid() |
newGuid() |
When you declare methods, use camelCase names. SharpTS resolves them to the PascalCase .NET equivalents.
.NET methods often have multiple overloads. SharpTS uses cost-based resolution to select the best match:
@DotNetType("System.Text.StringBuilder")
declare class StringBuilder {
constructor();
// Declare the overloads you need
append(value: string): StringBuilder;
append(value: number): StringBuilder;
append(value: boolean): StringBuilder;
toString(): string;
}
let sb = new StringBuilder();
sb.append("text"); // Calls Append(string)
sb.append(42); // Calls Append(double)
sb.append(true); // Calls Append(bool)Resolution priority (lower cost = preferred):
- Exact type match (e.g.,
number→double) - Lossless conversion (e.g.,
number→float) - Narrowing conversion (e.g.,
number→int) - Object fallback (any →
object)
The same cost scale is used in both compiled and interpreted modes, so an overload that resolves in one mode resolves the same way in the other — as long as the argument types are unambiguous.
When a method has multiple overloads that a TypeScript call can't distinguish
(e.g., you want ToInt32(int) instead of the default ToInt32(double)), use
@DotNetOverload("<signature>") to pin the target signature:
@DotNetType("System.Convert")
declare class Convert {
// Without the hint, runtime picks ToInt32(double) for a TS number.
// The hint narrows to ToInt32(int) — truncates 3.7 to 3 instead of
// rounding to 4.
@DotNetOverload("int")
static toInt32(value: number): number;
}
console.log(Convert.toInt32(3.7)); // 3The hint value is a comma-separated list of parameter types matching the
overload's signature. Recognized aliases: int, long, short, byte, sbyte,
uint, ulong, ushort, float/single, double, decimal, bool/boolean,
char, string, object, plus their System.* equivalents. Other types can
be named by their fully-qualified CLR name.
Use @DotNetOverload("constructor-sig") on a declared constructor to pin the
constructor overload similarly.
@DotNetType("System.Text.StringBuilder")
declare class StringBuilder {
constructor();
append(value: string): StringBuilder;
append(value: number): StringBuilder;
append(value: boolean): StringBuilder;
readonly length: number;
toString(): string;
}
let sb = new StringBuilder();
sb.append("Name: ");
sb.append("Alice");
sb.append(", Age: ");
sb.append(30);
sb.append(", Active: ");
sb.append(true);
console.log(sb.toString()); // Name: Alice, Age: 30, Active: True
console.log(sb.length); // 34@DotNetType("System.Guid")
declare class Guid {
static newGuid(): Guid;
static parse(input: string): Guid;
static readonly empty: Guid;
toString(): string;
}
let id = Guid.newGuid();
console.log(id.toString()); // e.g., "a1b2c3d4-..."
let parsed = Guid.parse("00000000-0000-0000-0000-000000000000");
console.log(parsed.toString()); // 00000000-0000-0000-0000-000000000000@DotNetType("System.DateTime")
declare class DateTime {
static readonly now: DateTime;
static readonly utcNow: DateTime;
static readonly today: DateTime;
readonly year: number;
readonly month: number;
readonly day: number;
readonly hour: number;
readonly minute: number;
toString(): string;
}
let now = DateTime.now;
console.log(now.year); // e.g., 2024
console.log(now.month); // e.g., 12
console.log(now.day); // e.g., 25@DotNetType("System.TimeSpan")
declare class TimeSpan {
static fromSeconds(value: number): TimeSpan;
static fromMinutes(value: number): TimeSpan;
static fromHours(value: number): TimeSpan;
static fromDays(value: number): TimeSpan;
add(ts: TimeSpan): TimeSpan;
readonly totalSeconds: number;
readonly totalMinutes: number;
readonly totalHours: number;
toString(): string;
}
let duration = TimeSpan.fromMinutes(90);
console.log(duration.totalHours); // 1.5
console.log(duration.totalSeconds); // 5400
let extra = TimeSpan.fromMinutes(30);
let total = duration.add(extra);
console.log(total.totalMinutes); // 120@DotNetType("System.Convert")
declare class Convert {
static toInt32(value: number): number;
static toInt32(value: string): number;
static toDouble(value: string): number;
static toBoolean(value: number): boolean;
static toString(value: boolean): string;
}
let rounded = Convert.toInt32(42.7); // 43
let parsed = Convert.toDouble("3.14159"); // 3.14159
let flag = Convert.toBoolean(1); // true
let text = Convert.toString(true); // "True"@DotNetType("System.String")
declare class String {
static format(format: string, ...args: object[]): string;
static concat(str0: string, str1: string): string;
static isNullOrEmpty(value: string): boolean;
}
let message = String.format("Hello {0}, you have {1} messages!", "Alice", 5);
console.log(message); // Hello Alice, you have 5 messages!
let formatted = String.format("{0} + {1} = {2}", 10, 20, 30);
console.log(formatted); // 10 + 20 = 30@DotNetType("System.Text.StringBuilder")
declare class StringBuilder {
constructor();
append(value: string): StringBuilder;
toString(): string;
}
// Regular TypeScript class
class Person {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
toFormattedString(): string {
// Use .NET StringBuilder inside TypeScript class
let sb = new StringBuilder();
sb.append("Person { name: ");
sb.append(this.name);
sb.append(", age: ");
sb.append(this.age.toString());
sb.append(" }");
return sb.toString();
}
}
let person = new Person("Bob", 25);
console.log(person.toFormattedString()); // Person { name: Bob, age: 25 }Methods that return this or the same type support chaining:
@DotNetType("System.Text.StringBuilder")
declare class StringBuilder {
constructor();
append(value: string): StringBuilder;
appendLine(): StringBuilder;
appendLine(value: string): StringBuilder;
toString(): string;
}
let result = new StringBuilder()
.append("Line 1")
.appendLine()
.append("Line 2")
.toString();You can declare and use multiple .NET types in the same file:
@DotNetType("System.Text.StringBuilder")
declare class StringBuilder {
constructor();
append(value: string): StringBuilder;
toString(): string;
}
@DotNetType("System.Guid")
declare class Guid {
static newGuid(): Guid;
toString(): string;
}
// Use both together
let sb = new StringBuilder();
sb.append("ID: ");
sb.append(Guid.newGuid().toString());
console.log(sb.toString());.NET properties are accessed without parentheses, methods require them:
@DotNetType("System.Text.StringBuilder")
declare class StringBuilder {
constructor();
readonly length: number; // Property - access as sb.length
toString(): string; // Method - call as sb.toString()
}
let sb = new StringBuilder();
console.log(sb.length); // Property access (no parentheses)
console.log(sb.toString()); // Method call (parentheses required)SharpTS can auto-generate TypeScript declarations from .NET types using the DeclarationGenerator:
var generator = new DeclarationGenerator();
string declaration = generator.GenerateForType("System.Text.StringBuilder");
Console.WriteLine(declaration);Output:
@DotNetType("System.Text.StringBuilder")
export declare class StringBuilder {
constructor();
constructor(capacity: number);
constructor(value: string);
append(value: string): StringBuilder;
append(value: number): StringBuilder;
append(value: boolean): StringBuilder;
appendLine(): StringBuilder;
appendLine(value: string): StringBuilder;
insert(index: number, value: string): StringBuilder;
remove(startIndex: number, length: number): StringBuilder;
replace(oldValue: string, newValue: string): StringBuilder;
clear(): StringBuilder;
toString(): string;
readonly length: number;
readonly capacity: number;
}The generator automatically maps .NET types to TypeScript:
| .NET Type | TypeScript Type |
|---|---|
void |
void |
string |
string |
bool |
boolean |
int, long, double, float, decimal |
number |
object |
unknown |
DateTime |
Date |
Task |
Promise<void> |
Task<T> |
Promise<T> |
List<T>, T[] |
T[] |
Dictionary<K,V> |
Map<K, V> |
HashSet<T> |
Set<T> |
Nullable<T> |
T | null |
The following .NET features are not currently supported:
| Feature | Status | Notes |
|---|---|---|
| Generic types | Not supported | Cannot declare List<T> directly |
ref / out parameters |
Not supported | Methods with ref/out params cannot be called |
| Events | Supported (both modes) | Use addEventListener/removeEventListener — see Events |
| Delegates | Supported (both modes) | TS functions auto-convert to delegate params — see Delegates |
| Indexers | Not supported | Cannot use obj[index] syntax |
| Operators | Not supported | Operator overloads not accessible |
| Extension methods | Not supported | Must call as static methods |
| Nullable value types | Partial | Generated as T | null but runtime behavior varies |
For unsupported features, consider:
- Creating a C# wrapper class that exposes a simpler API
- Using reflection-based interop via compiled TypeScript (see .NET Integration Guide)
Any TypeScript function can be passed where a .NET method expects a delegate — works in both interpreter and compiled modes. The interpreter builds a shim on demand so the delegate's Invoke signature round-trips through the TS callable:
@DotNetType("System.Collections.Generic.List`1")
declare class IntList {
constructor();
add(item: number): void;
forEach(action: (item: number) => void): void; // Action<int>
findAll(predicate: (item: number) => boolean): IntList; // Predicate<int>
}
let items = new IntList();
items.add(1); items.add(2); items.add(3);
items.forEach((n) => console.log(n));Supported delegate shapes:
| .NET type | TS shape |
|---|---|
Action |
() => void |
Action<T1…> |
(a: T1, …) => void |
Func<TResult> |
() => TResult |
Func<T1…, TResult> |
(a: T1, …) => TResult |
Predicate<T> |
(a: T) => boolean |
EventHandler |
(sender: any, args: any) => void |
EventHandler<T> |
(sender: any, payload: T) => void |
Incoming .NET values are normalized for TypeScript on entry to the callback
(integral numerics → number, complex objects → wrapped instance). The return
value is converted back to the delegate's declared return type; a throw inside
the TS callback propagates synchronously to the .NET caller.
Main-thread only. Delegate shims run the TS callable on whatever thread invoked the delegate. The interpreter is not thread-safe, so invoking a shim off the SharpTS event-loop thread (e.g., from a
Timer, aTaskcontinuation, or a background thread) is undefined behavior — races, corrupted state, or crashes are possible.A future release may introduce an opt-in marshalling hint (e.g.,
@DotNetCallback("marshal")) that hops off-thread invocations back to the event loop. Today, keep delegate sinks synchronous and on-thread.
Works in both interpreter and compiled modes.
TypeScript has no syntax for += on .NET events, so SharpTS exposes a DOM-style
API on any @DotNetType-wrapped instance or class:
@DotNetType("System.Timers.Timer")
declare class Timer {
constructor(interval: number);
start(): void;
stop(): void;
addEventListener(
name: string,
handler: (sender: any, args: any) => void
): void;
removeEventListener(
name: string,
handler: (sender: any, args: any) => void
): void;
}- Event names use the PascalCase .NET name (e.g.,
"Elapsed","StringReceived"). addEventListenerlooks up theEventInfoby name and wires a delegate shim.removeEventListenermust receive the same function reference originally passed toaddEventListener— the subscription is keyed by that reference so the underlyingRemoveEventHandlercall can find the matching shim.- Static events work the same way:
ClassName.addEventListener("Name", handler). - Subscribing the same
(name, handler)pair twice is idempotent.
The threading contract above applies: if the .NET event fires from a background
thread, the handler will be invoked on that thread. Prefer event sources that
fire on the event-loop thread, or wrap .NET APIs in a helper that re-raises
events on the main thread.
When a .NET method called through @DotNetType throws, SharpTS translates the
exception to a JavaScript-style error so try/catch in TypeScript works naturally.
The original .NET exception is preserved on e.cause for diagnostics.
| .NET exception | JS error (e.name) |
|---|---|
ArgumentNullException |
TypeError |
ArgumentException |
TypeError |
InvalidCastException |
TypeError |
NullReferenceException |
TypeError |
ArgumentOutOfRangeException |
RangeError |
IndexOutOfRangeException |
RangeError |
OverflowException |
RangeError |
DivideByZeroException |
RangeError |
FormatException |
SyntaxError |
| (everything else) | Error |
TargetInvocationException is unwrapped before classification, so the mapped
error reflects the actual failure, not the reflection wrapper.
@DotNetType("System.Guid")
declare class Guid {
static parse(input: string): Guid;
}
try {
Guid.parse("not-a-guid");
} catch (e) {
console.log(e.name); // "SyntaxError" (FormatException → SyntaxError)
console.log(e.message); // .NET's original message
// e.cause holds the original System.FormatException
}Currently the mapping is applied in interpreter mode. Compiled mode propagates
the raw .NET exception — DotNetExceptionMapper.ClassifyAsJsErrorName is a
public entry point so compiled-mode callers can opt into the same classification.
Error: .NET type 'X' not found
- Ensure the type name is fully qualified (e.g.,
System.Text.StringBuilder, notStringBuilder) - The type must be in an assembly loaded by the runtime (BCL types are always available)
Error: Method resolution fails at runtime
- Check that your camelCase declaration matches the PascalCase .NET method
- Verify the parameter types match what the .NET method expects
- Some .NET methods may have different overloads than expected
Error: Unknown decorator: DotNetType
- Decorators are enabled by default. If you used
--noDecorators, remove that flag. @DotNetTypeis a built-in compiler decorator, not a user-defined one
Error: @DotNetType: .NET type '…' not found in any loaded assembly.
In interpreted mode the type is resolved at the point the declare class
statement executes, from the set of assemblies currently loaded into the
process. If your type lives in a third-party assembly, make sure it's
loaded before the script runs — e.g., reference it from the host app or
Assembly.LoadFrom it up front.
- .NET Integration Guide - Compiling TypeScript for C# consumption
- Execution Modes - Interpreted vs compiled mode details
- Code Samples - TypeScript to C# mappings