DEV Community

Daniel Ferreira Monteiro Alves
Daniel Ferreira Monteiro Alves

Posted on • Originally published at danfma.dev.br

Write your domain once in C#, ship idiomatic TypeScript with Metano

If you build a full-stack product with a .NET backend and a TypeScript frontend, you have almost certainly described the same domain twice. The User entity, the Order status enum, the small validation rule, the JSON contract: written once in C#, then mirrored by hand in TypeScript. The mirror drifts. Someone adds a field on one side and forgets the other. A bug ships.

I got tired of maintaining that second copy, so I built Metano.

Metano is a Roslyn-powered transpiler. You keep your domain in C#, as the single source of truth, and Metano generates real TypeScript from it. Not declaration stubs, not just type shapes: actual behavior. Records, methods, operators, guards, LINQ, async, exceptions, and JSON serializer contracts come across as idiomatic TypeScript that fits the tooling you already use.

Why not just use what already exists

A few approaches get close, and each one stops short of what I wanted:

  • Hand-written type mirrors. The honest baseline, and the one that drifts. Every change is two changes, and the compiler on neither side knows about the other.
  • OpenAPI / NSwag codegen. Great for client SDKs and DTO shapes, but it generates data shapes, not behavior. Your ToggleCompleted() method does not come along.
  • Blazor WebAssembly. This ships the .NET runtime into the browser. It is .NET running on the client, not idiomatic TypeScript your frontend team reads and owns, and the runtime weight is real.
  • Fable. Excellent project, but it is built around F#. If your backend and your team live in C#, that is a language switch, not a code-sharing strategy.

Metano sits in the gap: C# in, idiomatic TypeScript out, with no .NET runtime in the browser and only small helper imports when the emitted code actually needs them.

Show, do not tell

Here is a record written in C#:

using Metano.Annotations;

[assembly: TranspileAssembly]
[assembly: EmitPackage("sample-todo")]

namespace SampleTodo;

[StringEnum]
public enum Priority { Low, Medium, High }

public record TodoItem(string Title, bool Completed = false, Priority Priority = Priority.Medium)
{
    public TodoItem ToggleCompleted() => this with { Completed = !Completed };

    public TodoItem SetPriority(Priority priority) => this with { Priority = priority };

    public override string ToString() => $"[{(Completed ? "x" : " ")}] {Title} ({Priority})";
}
Enter fullscreen mode Exit fullscreen mode

And here is what Metano generates:

export const Priority = {
  Low: "low",
  Medium: "medium",
  High: "high",
} as const;

export type Priority = (typeof Priority)[keyof typeof Priority];

export class TodoItem {
  constructor(
    readonly title: string,
    readonly completed: boolean = false,
    readonly priority: Priority = "medium",
  ) {}

  toggleCompleted(): TodoItem {
    return this.with({ completed: !this.completed });
  }

  setPriority(priority: Priority): TodoItem {
    return this.with({ priority: priority });
  }

  toString(): string {
    return `[${this.completed ? "x" : " "}] ${this.title} (${this.priority})`;
  }

  equals(other: any): boolean {
    return (
      other instanceof TodoItem &&
      this.title === other.title &&
      this.completed === other.completed &&
      this.priority === other.priority
    );
  }

  with(overrides?: Partial<TodoItem>): TodoItem {
    return new TodoItem(
      overrides?.title ?? this.title,
      overrides?.completed ?? this.completed,
      overrides?.priority ?? this.priority,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The C# record keeps its value semantics on the TypeScript side. You get structural equals, a non-destructive with, and the string enum rendered as a const object plus a union type, which is the idiom most TS codebases already reach for. No decorators to wire up, no runtime to load, just code your bundler treats like any other module.

How it works

Metano is built on Roslyn, so it understands your C# semantically rather than scraping syntax. The pipeline parses your project, lowers it into a shared intermediate representation, and then emits target code from that IR.

TypeScript is the production target today. There is also an experimental Dart/Flutter backend riding the same IR, which exists mostly to validate that the architecture is genuinely multi-target rather than a TypeScript-shaped special case. Keeping the IR honest is what makes new backends tractable later.

What it handles today

More than I expected to reach when I started:

  • C# records, classes, structs, interfaces, delegates, inheritance, generics, overloads, extension methods, nullable types, async, exceptions, pattern matching, and nested types.
  • BCL mappings for collections, LINQ, decimal, Guid, temporal types, BigInteger, tasks, strings, and math.
  • System.Text.Json source-generation metadata is emitted as a TypeScript SerializerContext, so your serialization contracts stay in sync too.
  • Output controls via attributes when you want to shape the emitted code: [StringEnum], [PlainObject], [Branded], [GenerateGuard], [ObjectArgs], [Optional], [Import], and more.
  • Cross-project package generation with [EmitPackage], generated barrels, and package-aware imports.

The generated TypeScript works with Bun, Vite, Vitest, Biome, ESLint, bundlers, and source maps. It behaves like code a person wrote.

Honest scope

Metano is young and moving quickly. TypeScript is the mature, production path; the Dart backend is intentionally documented as experimental while its coverage grows. It is built for sharing domain code and logic, not for porting an entire app or replacing your UI framework, although the repo does include a set of counter samples that explore component and MVU-style patterns, including SolidJS interop, if you want to see how far the idea stretches.

If you are looking for "write C# and never touch the frontend again," that is not what this is. If you are looking for "stop maintaining a second copy of my domain," that is exactly this.

Try it

dotnet add package Metano
dotnet add package Metano.Build
Enter fullscreen mode Exit fullscreen mode

Point your C# project at a generated TypeScript package:

<PropertyGroup>
  <MetanoOutputDir>../my-domain-ts/src</MetanoOutputDir>
  <MetanoClean>true</MetanoClean>
</PropertyGroup>
Enter fullscreen mode Exit fullscreen mode

Then dotnet build. There is also a global tool if you prefer manual runs:

dotnet tool install --global Metano.Compiler.TypeScript
dotnet metano-typescript -p path/to/YourProject.csproj -o path/to/output/src --clean
Enter fullscreen mode Exit fullscreen mode

What is next

I will continue to evolve Metano by exploring more use cases and making it more complete, and along the way, I'm evaluating other approaches like JSX generation.

The roadmap I am most curious about is a WebAssembly backend on the same IR, using the modern WasmGC proposal so the output stays lean instead of shipping a runtime. That raises a genuinely interesting compiler problem: how to lower C# value types onto WasmGC's reference-typed structs while preserving copy semantics. I will write that one up separately. Also, evaluate whether using WasmGC will allow me to transfer data between Wasm and JS with minimal friction, which opens up space for more interesting prototypes.

If Metano is useful to you, or if it breaks in an interesting way, I would love your feedback. Issues and stars both help me prioritize.

Top comments (0)