Skip to content

TypeGen — Configuration & output

  1. Defaults
  2. Global TypeScript / OpenApi / Python / Zod / TanStackQuery blocks in ITypeGenConfigurator
  3. ForType<T>() per-type fluent overrides
  4. Class / property attributes ([TsName], [OpenApiProperty], etc.)

Project-wide — ITypeGenConfigurator (fluent DSL)

Section titled “Project-wide — ITypeGenConfigurator (fluent DSL)”

One class per project, picked up automatically by the generator. The Configure method body is a fluent DSL parsed at compile time — it’s never actually invoked at runtime, so all arguments must be literal expressions (string literals, enum members, constants). Anything dynamic is invisible and surfaces as diagnostic TG0013.

public sealed class TypeGenConfig : ITypeGenConfigurator
{
public void Configure(ITypeGenBuilder b)
{
b.TypeScript(ts =>
{
ts.OutputDir = "../client/src/api";
ts.FileLayout = TypeScriptFileLayout.SingleFile;
ts.SingleFileName = "models.ts";
ts.PropertyNameStyle = NameStyle.CamelCase;
});
b.OpenApi(oa =>
{
oa.OutputPath = "../api/openapi.yaml";
oa.Title = "Order Service";
oa.Version = "2.1.0";
oa.Description = "Public API for the order service.";
});
b.TanStackQuery(q =>
{
q.OutputDir = "../client/src/api";
q.SingleFileName = "api.gen.ts";
q.BaseUrlExpression = "import.meta.env.VITE_API_URL";
// q.FileLayout = QueryFileLayout.SplitByTag;
// q.ApiClientImportPath = "./http-client";
// q.ApiClientName = "request";
});
// Per-type overrides for DTOs you can't (or don't want to) annotate —
// e.g. types from a referenced library.
b.ForType<Order>()
.TsName("OrderDto")
.OutputDir("generated/orders");
b.ForType<InternalAudit>().Ignore();
// Fluent-only discovery — no [GenerateTypes] needed on the class.
// .WithGeneratedTypes(targets) opts the type into emission for the listed
// targets. Combine with the usual chain (TsName, .Property, etc.) to
// tweak the output.
b.ForType<Article>()
.WithGeneratedTypes(TypeTarget.TypeScript | TypeTarget.OpenApi)
.TsName("ArticleDto")
.Property(p => p.Body).TsType("string | null");
}
}

For the emitted default TanStack Query fetch client, BaseUrlExpression is evaluated safely. If the expression is unset, empty, or throws, the client falls back to window.location.origin in browsers.

Discovery vs override. Without .WithGeneratedTypes(...), the fluent block is a no-op for types that don’t carry [GenerateTypes] — the chain just sits there registering overrides for a class TypeGen never sees. Adding the marker method is the explicit “yes, emit code for this type” signal.

Targeting generic types — ForType(typeof(...))

Section titled “Targeting generic types — ForType(typeof(...))”

Open generics can’t be passed as a C# type argument (ForType<Base<>>() is a syntax error), so pair the second ForType overload with typeof(...):

// Open form — the canonical way to target every Base<T> instantiation at once.
b.ForType(typeof(Base<>))
.Property("InternalTrace").TsIgnore()
.Property("DebugToken").OpenApiIgnore();
// Closed form works too — both collapse onto the same Base<T> key that the
// schema model uses, so a single line covers Base<int>, Base<string>, etc.
b.ForType(typeof(Base<int>))
.TsName("BaseDto");

Strongly-typed lambda selectors aren’t available on this path (the type argument is erased) — use the string-based Property(name) overload. It’s parsed as a literal; nameof(T.Member) also works since it’s a compile-time constant.

b.ForType<Order>().Property(nameof(Order.Email)).TsName("emailAddress");

Prefer a closed form for typed selectors. When you want refactor-safe c => c.Property lambdas on a generic, use b.ForType<Base<SomeT>>() with any valid closed instantiation — the parser normalizes to the open Base<T> key anyway, so one override covers every construction. Reach for ForType(typeof(Base<>)) only when no closed form satisfies Base<T>’s type constraints (rare).

When the parent type carries [CrudApi] (or [CreateDto]/[UpdateDto]/ [ResponseDto]), Dto generates Create{X}Request / Update{X}Request / {X}Response companion records. Roslyn’s source-generator architecture prevents TypeGen from resolving those records as symbols in the same compilation pass — so you can’t write b.ForType<CreateArticleRequest>() and expect the symbol to bind.

TypeGen handles this with a synthesis path: when the fluent type argument matches the Create{X}Request / Update{X}Request / {X}Response naming pattern AND the symbol is unresolvable, TypeGen looks for the parent type X in the user’s assembly and emits a synthetic schema from its properties.

// Standalone — no [GenerateTypes] on Article needed, no anchor needed either.
// Generates ONLY the Create variant as TS + OpenAPI; Update/Response are NOT
// emitted because they aren't listed.
b.ForType<CreateArticleRequest>()
.WithGeneratedTypes(TypeTarget.TypeScript | TypeTarget.OpenApi)
.TsName("ArticleDto");

Per-companion fluent overrides (TsName, OpenApiName) apply on the synthesized schema. The attribute path ([CrudApi] on parent, no fluent) still emits all three companions automatically — fluent is for chirurgical opt-in to a subset.

Only one ITypeGenConfigurator per project — multiple implementations fire diagnostic TG0010. Unrecognized fluent calls fire TG0012.

Wins over project-wide settings and per-type fluent overrides. Precedence from lowest to highest: defaults → TypeScript/OpenApi global blocks → ForType<T>() per-type fluent → class/property attributes.

TypeGen writes generated files to disk through two complementary paths:

  1. Live writes from the generator (default, on every IDE save). The source generator itself calls File.WriteAllText for each emitted file. Since Roslyn re-runs the generator on each compile cycle the IDE triggers, your .ts / .yaml / .py files refresh as soon as you save the source. Wrapped in try/catch — sandboxed analyzer hosts (some Rider configs, restricted CI containers) silently fall back to path #2.
  2. MSBuild post-build target (shipped in build/ZibStack.NET.TypeGen.targets, auto-imported by the NuGet package). Reads a manifest .g.cs the generator emits to obj/generated/ and writes the same set of files via an inline RoslynCodeTaskFactory C# task. Always runs on dotnet build, regardless of whether path #1 succeeded.

Both paths skip writes when content is byte-identical (mtime stays stable — keeps file-watcher-driven dev servers like Vite from looping). Both sweep stale files in the touched directories: any file carrying our @generated banner that isn’t in the current emit set gets deleted, so renames don’t leak orphaned outputs.

No companion task DLL, no reflection over the built assembly — the inline task is ~60 lines of C# directly in the .targets file. Files are skipped when their content is unchanged (stable mtimes, no file-watcher thrash in frontend dev servers).