JS Module Source Generator — Support Matrix
This is the full reference for what the typed parser does with each TypeScript construct, applied uniformly to both .ts and .d.ts source files. The main article gives the high-level picture; this page is the one to scan when you want to know whether a specific pattern is supported, what it maps to, and whether the generator will emit a BAPI002 warning.
Every entry is grouped by category. For each, you get a small TypeScript sample, the corresponding C# output, and a short explanation of why. When an entry falls back to object, the table tells you whether that's intentional (no diagnostic) or a known limitation (the generator emits BAPI002 so you see it in your build log).
Source file support. Preview.7 onwards, the same typed parser reads both .ts and .d.ts. For .ts it skips function bodies automatically (so you don't need a tsc --emitDeclarationOnly step). Priority when both exist for the same module stem: .d.ts > .ts > .js (JSDoc-only fallback).
Primitives
| TypeScript | C# |
|---|---|
string |
string |
number |
double |
boolean |
bool |
void |
void (method returns Task) |
undefined |
treated as void for return types |
any |
object |
null |
object |
never |
void |
Why number → double. JavaScript numbers are IEEE-754 double-precision floats. There is no integer/float distinction on the JS side. Mapping to double preserves the full range and precision; mapping to int would silently truncate any value over 2^31. If you know a value is always an integer, you can [JSInvokable] a method on a C# class whose signature uses int — but the generator can't assume that from a plain .d.ts.
Why any and null → object without a warning. These are intentional fallbacks. The consumer has explicitly chosen the loosest typing. Emitting BAPI002 would be noise.
Containers
| TypeScript | C# |
|---|---|
T[] |
T[] |
Array<T> |
T[] |
Record<string, T> |
System.Collections.Generic.Dictionary<string, T> |
Promise<T> |
unwrapped to T (method returns Task<T>) |
T \| null, T \| undefined in property position |
T? |
Why Promise<T> is unwrapped. Every generated method is already async Task<T>. Keeping the Promise<...> wrapper in the C# signature would mean Task<Task<T>> at the call site — nonsense. The generator strips it during mapping.
Why Record<string, T> and not IReadOnlyDictionary. JSON deserialization into a concrete Dictionary<,> just works. An interface type would need a custom converter. Can change later if it becomes a demand.
Arrays of complex types. DragConfig[], Array<Behavior>, and nested forms like Record<string, Behavior> are all resolved recursively. If any element type is unrecognized, that specific element position falls back to object and emits BAPI002 — the array wrapper itself is fine.
Interfaces
| TypeScript | C# |
|---|---|
export interface Foo { ... } |
public sealed class Foo with [JsonPropertyName] per property |
interface Foo { ... } (no export) |
Same as above |
| Interface referenced from a function parameter | the sealed class |
| Interface referenced from a property | the sealed class |
Exported and non-exported interfaces both become records. TypeScript's export keyword controls module-level import visibility; it has no bearing on the JSON shape that crosses the JS/C# boundary. The generator treats both the same way. This was fixed in 0.1.0-preview.3 after a brief period when only exported interfaces were recognized.
Property names are PascalCased for C# and re-tagged with [JsonPropertyName] so they round-trip through System.Text.Json using the original casing on the wire.
Cross-file references are not resolved. If moduleA.d.ts references SharedType declared in moduleB.d.ts, the parser in moduleA has never seen SharedType and falls back to object with a BAPI002 warning. Work around it by redeclaring the type in each .d.ts that needs it. Each .d.ts is its own world — keeping it that way keeps the generator simple and its behavior predictable.
String literal unions → enums
| TypeScript | C# |
|---|---|
'a' \| 'b' \| 'c' as a property type |
enum named <InterfaceName><PropertyName> with [JsonStringEnumMemberName("a")] per member, decorated with [JsonConverter(typeof(JsonStringEnumConverter<...>))] |
'kebab-case' values |
PascalCased to KebabCase; original preserved via [JsonStringEnumMemberName] |
Why enums instead of string. A plain string loses the invariant that only certain values are valid. An enum gives compile-time enforcement and IntelliSense. JSON round-tripping is handled by the attributes — values serialize to the original TypeScript literal.
Why the generated name is <Interface><Property>. Unions are usually specific to one property, so the name couples them. If the same union appears on two properties of two different interfaces, they produce two distinct enum types — that's a minor footgun we can address later by deduplicating equal unions across a file if it becomes a pattern.
Blazor interop types
DotNetObjectReference as a direct function parameter
// .d.ts — the stub declaration satisfies TypeScript
interface DotNetObjectReference {}
export function createDrag(dotNetRef: DotNetObjectReference, config: DragConfig): number;
// Generated — the method is generic; T is inferred at each call site
// (`using Microsoft.JSInterop;` is in the file header, so DotNetObjectReference is unqualified)
public async System.Threading.Tasks.Task<double> CreateDragAsync<
[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(
System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicMethods)]
TDotNetRef>(
DotNetObjectReference<TDotNetRef> dotNetRef,
DragConfig config) where TDotNetRef : class { ... }
Why generic over TDotNetRef instead of typed as object. DotNetObjectReference<T> is a sealed generic class; the non-generic DotNetObjectReference is a static factory and cannot be used as a parameter type (that was preview.4's bug — CS0721). We also can't pick a concrete T from the .d.ts because T lives in the consumer's assembly, not TypeScript. The compromise: make the method generic, let the consumer's argument type drive inference. var r = DotNetObjectReference.Create(this); await m.CreateDragAsync(r, cfg); — TDotNetRef resolves to whatever class this is.
Why the stub interface DotNetObjectReference {} is recognized and skipped. TypeScript requires some declaration for any name used in a signature. If the generator emitted a JsModules.DotNetObjectReference class for the stub, it would collide with Microsoft.JSInterop.DotNetObjectReference in any consumer file that had using JsModules; in scope — a pre-preview.4 bug. The skip-list treats it as a Blazor interop primitive, not a consumer shape.
Why the DynamicallyAccessedMembers(PublicMethods) attribute. DotNetObjectReference<TValue> itself carries this annotation, which the AOT/trimmer uses to preserve the target object's [JSInvokable] methods. When our generated method forwards TDotNetRef to DotNetObjectReference<TDotNetRef>, without the matching annotation the trimmer emits IL2091. With it, Blazor WebAssembly AOT builds stay quiet.
Multiple DotNetObjectReference parameters in one function. Each gets its own type parameter (TDotNetRef, TDotNetRef1, TDotNetRef2, …), independently inferred per-argument. The consumer can pass references to different classes; the C# compiler doesn't try to unify them.
DotNetObjectReference in other positions — fallback to object
| Where it appears | C# output | Diagnostic |
|---|---|---|
| As an interface property | object |
none (intentional) |
| As a return type | object |
none (intentional) |
Inside Array<...>, Record<string, ...>, or any nested container |
object |
none (intentional) |
Why these cases fall back. The generic-method trick only works when DotNetObjectReference is a top-level parameter — that's the only position where C# can infer T from a call-site argument. In other positions (record property, array element, return type) we'd need to promote the containing type to generic too, which .d.ts can't convey. The generator doesn't emit BAPI002 for these because they're a known, documented fallback — not silent degradation of an unrecognized type.
In practice this is fine: DotNetObjectReference is almost always passed as a direct argument when wiring up a JS ↔ .NET callback. The other positions are rare.
Nullability / optionality
| TypeScript | C# |
|---|---|
foo?: string in a property |
string? Foo |
foo?: number in a property |
double? Foo |
foo?: CustomType in a property |
CustomType? Foo (adds ? if missing) |
foo?: Type as a function parameter |
Type? foo |
foo?: DotNetObjectReference |
DotNetObjectReference<TDotNetRef>? foo (generic method, nullable generic param) |
Why ? only on value-producing positions. Generated records use required for non-optional properties and plain get; init; for optional ones. The nullable ? conveys the JSON-shape contract both to the C# compiler and to System.Text.Json — deserialization sees a missing key and sets the property to null / default without complaint.
JSDoc → C# XML documentation
JSDoc comments in your .ts / .d.ts flow through to C# XML docs so IntelliSense shows the same text a TypeScript editor would. Three positions are recognized:
| JSDoc position | C# output |
|---|---|
Leading text on an export function |
/// <summary> on the generated async method |
@param name - text on a function |
/// <param name="…"> on the matching C# parameter |
@returns text on a function |
/// <returns> on the method |
Leading text on an interface declaration |
/// <summary> on the generated sealed class |
| Leading text on an interface property | /// <summary> on the generated property |
Example round-trip:
/** Configuration for a drag context. */
export interface DragConfig {
/** CSS selector for the container. */
container: string;
}
/// <summary>Configuration for a drag context.</summary>
/// <remarks>Generated from the TypeScript interface <c>DragConfig</c>.</remarks>
public sealed class DragConfig {
/// <summary>CSS selector for the container.</summary>
[JsonPropertyName("container")]
public required string Container { get; init; }
}
The <remarks> tag is added automatically whenever the interface has a JSDoc summary — it preserves the "this was generated from X" breadcrumb without losing the author's description.
How the summary text is extracted. The parser takes everything between /** and the first @tag line. Multi-line JSDoc is collapsed to a single space-joined string — good for <summary> which expects one paragraph. Lines starting with the canonical * prefix have it stripped automatically. Content after the first @tag is either used by the tagged-section parser (for functions, @param / @returns) or ignored (for properties — no per-property tag support today).
No JSDoc? The generator falls back to a descriptive boilerplate: Maps to TypeScript property <c>foo</c> for properties, Generated from the TypeScript interface <c>Foo</c> for interfaces. Consumers still get some documentation, never an empty <summary>.
Attachment is strict. A JSDoc block attaches only to the declaration immediately below it (whitespace-only separation). A non-JSDoc comment or another declaration between them breaks the attachment — that prevents us from accidentally leaking someone else's docs onto a later member.
Diagnostics
BAPI002 — Unknown TypeScript type
Emitted when the parser meets a type it cannot map. The message identifies the exact function, parameter (or interface + property), and the TypeScript type text that couldn't be resolved.
BAPI002: Unknown TypeScript type 'FancyGeneric<T>' at createDrag(config) — falling back to 'object'.
Causes that trigger BAPI002:
- Complex generics other than the supported short list (
Array<T>,Promise<T>,Record<string, T>). E.g.Foo<T extends Bar>, conditional types, mapped types, and type aliases built from those. - Intersection types like
A & B. A single interface that lists all members is better for interop anyway. - Cross-file interface references. If you reference a type declared in a different
.d.tsfile, the parser can't find it and falls back. - Typos and simple typos in type names. Same code path as "unknown reference" — you'll see the typo in the warning message.
Intentional mappings that do NOT emit BAPI002
These are fallbacks by design, not silent failures. No warning is emitted:
any→objectnull→objectDotNetObjectReferencein non-direct-param positions →object
Unsupported / not recognized
These patterns don't get parsed at all — the generator ignores them silently because there's no meaningful C# mapping. If you need one of these, a small named re-export in your .d.ts / .js is usually the cleanest workaround.
Function declaration forms
export default function ...— not picked up. Blazor JS modules call named functions by string (module.InvokeAsync("foo", ...)), so default exports don't fit the dispatch model. Workaround: export under a named alias —export function foo(...) { ... }.export const foo = (...) => ...(arrow function exports) — not picked up. Same reason: the parser keys onexport function ...declarations specifically.- Class methods — not picked up. A class instance can't be invoked as a module-level function by name. If you have a JavaScript class, expose its methods as named module-level functions and delegate to an instance.
TypeScript features
- Type aliases (
type Foo = ...) — not expanded. Use aninterfacefor shape declarations that should become C# records. - Conditional types (
T extends U ? X : Y) — not evaluated. - Mapped types (
{ [K in keyof T]: ... }) — not expanded. - Namespaces (
namespace N { ... }) — not recognized. Declare everything at the file's top level. - Module re-exports (
export * from '...') — not followed. Each.d.tsis parsed in isolation.
What to do when you hit a limitation
The generator is meant to cover the common shape of hand-written interop .d.ts files — not to be a full TypeScript compiler. When you hit something on this page that doesn't work for you, the usual recipes:
- For complex generic / conditional / mapped types: declare a simpler
interfacein the.d.tsthat matches the JSON shape on the wire. Keep the fancy TypeScript for your JS code's own internal types if you want; give the interop boundary a plain interface. - For intersection types: flatten into a single
interfacethat lists all members directly. Same JSON shape, clearer on both sides. - For cross-file references: redeclare the type in each
.d.tsthat needs it, or move both modules into one file. The duplication is explicit and easy to audit. - For default / arrow / class exports: add a named function re-export that forwards to them.
- For a pattern that's none of the above and you think should work: open an issue with the
.d.tssnippet, the expected C# output, and why it matters. The limits are mostly conservative, not structural — if there's a clean mapping we missed, we'll add it.