Metano supports sharing types across multiple C# projects that each produce their own npm package. This guide explains how it works and how to set it up.
You have a solution with multiple C# projects:
MySolution/
├── MySolution.Shared/ # → npm: @acme/shared
├── MySolution.Users/ # → npm: @acme/users (depends on Shared)
└── MySolution.Orders/ # → npm: @acme/orders (depends on Shared + Users)
Each project transpiles to its own npm package. Types in Orders can reference
types in Users and Shared, and the generated TypeScript gets the right
import statements and package.json entries automatically.
In each .csproj, add an AssemblyInfo.cs (or similar) with:
using Metano.Annotations;
[assembly: TranspileAssembly]
[assembly: EmitPackage("@acme/shared", Version = "workspace:*")][assembly: TranspileAssembly]— marks the assembly for whole-project transpilation (optional, but usually what you want)[assembly: EmitPackage(name, Version)]— the npm package name and the version specifier to use in consumerpackage.json#dependencies
The Version field is optional. Common values:
"workspace:*"— for Bun/pnpm/Yarn workspace siblings"^1.2.3"— for a published npm version- Omit entirely — Metano falls back to
workspace:*if the assembly has no explicitVersion, or^Major.Minor.Patchfrom the assembly'sIdentity.Versionotherwise
In MySolution.Users.csproj:
<ItemGroup>
<ProjectReference Include="../MySolution.Shared/MySolution.Shared.csproj" />
</ItemGroup>This is a normal .NET project reference. Metano picks up the reference during compilation, walks the referenced assembly's public types, and registers them in its cross-assembly type map.
The TypeScript CLI also finds the target package root by walking upward from
MetanoOutputDir until it sees a package.json. If no package file is found,
it falls back to the legacy convention: the parent directory of the output
folder. Set MetanoPackageRoot when your generated files live somewhere unusual.
When a consumer uses a type from a referenced project, the transpiler:
- Resolves the type origin — finds the producer assembly's
[EmitPackage]name + the type's namespace path. - Emits an
importstatement —import { TypeName } from "@acme/shared/namespace/path" - Adds a
package.jsondependency — the consumer's generatedpackage.jsongets"@acme/shared": "workspace:*"automatically. - Merges multiple names from the same barrel — if you import
Money,Currency, andPriceall from@acme/shared/finance, they get one combined import line.
Shared/Money.cs:
using Metano.Annotations;
[assembly: TranspileAssembly]
[assembly: EmitPackage("@acme/shared")]
namespace Acme.Shared.Finance;
public record Money(decimal Amount, string Currency);Users/User.cs:
using Acme.Shared.Finance;
namespace Acme.Users;
public record User(string Id, string Name, Money Balance);Generated @acme/users/src/user.ts:
import { HashCode } from "metano-runtime";
import { Money } from "@acme/shared/finance";
export class User {
constructor(
readonly id: string,
readonly name: string,
readonly balance: Money,
) {}
// …
}Generated @acme/users/package.json:
{
"name": "@acme/users",
"dependencies": {
"@acme/shared": "workspace:*",
"metano-runtime": "^0.1.0"
}
}Metano lays generated files out by their full kebab-cased C# namespace under the package root — the on-disk path mirrors the fully-qualified name, with nothing stripped (ADR-0025). The package name and the namespace are therefore both present in a cross-package import:
| Producer type | Generated import |
|---|---|
Acme.Shared.Finance.Money |
from "@acme/shared/acme/shared/finance" |
Acme.Shared.Finance.Currency |
from "@acme/shared/acme/shared/finance" |
A cross-package import resolves to the namespace's leaf barrel (index.ts
inside that namespace directory), so multiple names from the same namespace merge
onto one import line. (This mirrors how the producing package emits its own files,
which keeps the two layouts in lock-step.)
Within a single package, references between generated types import the
defining file directly — from "#/acme/shared/finance/money" across
namespaces, or from "./money" within the same namespace — never through a barrel.
This keeps internal correctness independent of barrel emission and structurally
avoids ESM import cycles.
A single opt-in root aggregation barrel (--namespace-barrels /
MetanoNamespaceBarrels) re-exports the whole hierarchy under nested
export namespace blocks for consumers who prefer one entry point. It is opt-in
because, unlike the per-namespace leaf barrels, it defeats tree-shaking.
If two referenced assemblies both declare a public type called User, Metano
disambiguates at the symbol level (via Roslyn), not by string matching. Each
User ends up correctly imported from its own producing package — no collision.
When you publish your packages to npm:
- Set
[assembly: EmitPackage(name, Version = "^x.y.z")]with the concrete published version - Consumers see
"@acme/shared": "^x.y.z"in their generatedpackage.json - The normal npm/yarn/pnpm/bun install flow resolves dependencies
For monorepo development, use Version = "workspace:*" (or omit Version
entirely to let it fall back automatically) so Bun/pnpm/Yarn workspaces link
the packages symbolically.
If a referenced assembly has [assembly: TranspileAssembly] but no
[assembly: EmitPackage], the transpiler doesn't know what npm package name to
use for imports. You'll get one MS0007 error per type that's referenced from
that assembly.
Fix: add [assembly: EmitPackage("name")] to the producer.
If two types share [EmitInFile("foo")] but live in different C# namespaces,
Metano rejects the setup because consumers can't resolve which file to import
from. Move them into the same namespace or use different file names.
The current cross-project flow works for source-available dependencies (via
ProjectReference in the same solution, or source packages). For NuGet packages
that don't include source, Metano needs a metadata sidecar file called
.metalib to provide the type signatures without full source access.
Status: not yet implemented. Tracked as
issue #27 — schema, generation,
embedding, and consumption. The design rationale for reusing Roslyn compilation
references as the primary cross-assembly channel (and leaving .metalib as an
additive follow-up) is captured in
ADR-0004.
For now, share source via ProjectReference within the same solution or via a
monorepo.
- Attribute Reference —
[EmitPackage],[EmitInFile]details - Architecture Overview — how cross-assembly discovery works internally
- SampleTodo.Service sample — real cross-project example