TypeGen — Advanced type features
[JsonExtensionData] → schema-level additionalProperties
Section titled “[JsonExtensionData] → schema-level additionalProperties”When a property carries [JsonExtensionData] (System.Text.Json or Newtonsoft.Json),
that property is not emitted as a regular field. Instead it bumps the parent
schema with additionalProperties (OpenAPI) / an index signature (TypeScript) —
matching what the runtime serializer actually does (catch every unmapped JSON key).
[GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.OpenApi)]public class Order{ public int Id { get; set; } public string Customer { get; set; } = "";
[JsonExtensionData] public Dictionary<string, object?> Extra { get; set; } = new();}→ TypeScript:
export interface Order { id: number; customer: string; [key: string]: unknown; // catches unmapped JSON keys}→ OpenAPI:
Order: type: object required: [Id, Customer] properties: Id: { type: integer, format: int32 } Customer: { type: string } additionalProperties: trueTyped value variant. When the dictionary’s value type is concrete (e.g.
Dictionary<string, int>, Dictionary<string, Tag>), the emitters carry the
type through:
- TypeScript:
[key: string]: number | unknown;— union withunknownkeeps named properties (which may not satisfynumber) compatible with the index signature in strict mode. - OpenAPI:
additionalProperties: { type: integer, format: int32 }(or{ $ref: ... }for user-DTO values).
Inheritance. When the parent class is in the model, a derived class with
[JsonExtensionData] emits its index signature inside the body of the
extends/allOf shape — base properties + derived-only props + the additional
properties marker, all in the right place.
Computed & immutable properties
Section titled “Computed & immutable properties”The C# accessor shape drives Create/Update participation and the generated client contract:
| C# property | TS | OpenAPI | Python (Pydantic) | Dto Create | Dto Update |
|---|---|---|---|---|---|
public int X { get; set; } | x: number; | required | x: int | ✓ | ✓ |
public int X { get; init; } | x: number; | required | x: int | ✓ | — (init) |
public int X { get; } | readonly x?: number; | readOnly: true + not required | x: int | None = Field(default=None, frozen=True) | — | — |
public int X => Y * Z; | readonly x?: number; | readOnly: true + not required | x: int | None = Field(default=None, frozen=True) | — | — |
public int X { get; private set; } | readonly x?: number; | readOnly: true + not required | x: int | None = Field(default=None, frozen=True) | — | — |
Why the ? / optional: TypeGen emits a single schema per type, used for both
reading responses and constructing request payloads. Leaving computed fields
strictly required would force clients to provide values they shouldn’t set
(server-owned), so readonly props become optional at the TS / Python level
and excluded from the OpenAPI required list. On the response side this
still types-checks — consumers read the value normally; it’s just not
enforced at the type system during construction. readOnly: true / readonly /
frozen=True still prevent mutation after the value lands on the object.
init-only properties participate in Create (that’s what init is for —
ctor-time assignment), but drop out of Update (an init accessor rejects
post-construction writes at runtime). Records with positional syntax
(public record Order(string Sku)) fall under this bucket — Sku ends up in
Create, not Update, matching the record’s immutable-by-design nature.
String enum converters
Section titled “String enum converters”When an enum carries [JsonConverter(typeof(JsonStringEnumConverter))] (or the
generic JsonStringEnumConverter<T> in .NET 8+, or Newtonsoft’s
StringEnumConverter), runtime JSON uses the member name, not the
underlying integer. TypeGen picks this up and emits matching client code so
deserialisation lines up automatically:
[GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.Python)][JsonConverter(typeof(JsonStringEnumConverter))]public enum OrderStatus { Pending, Shipped, Delivered }→ TypeScript (default — Enum.Union):
export type OrderStatus = "Pending" | "Shipped" | "Delivered";Modern idiomatic TS: tree-shakes better, narrower types, maps 1:1 to the wire format, and matches the discriminated-union pattern already used for polymorphic types. Consumers use the literal directly (status: "Pending") — the runtime enum object is gone.
Opt back into the legacy enum form when a consumer relies on the runtime object (iteration, reverse lookup):
public sealed class TypeGenConfig : ITypeGenConfigurator{ public void Configure(ITypeGenBuilder b) => b.TypeScript(ts => ts.EnumStyle = TsEnumStyle.Enum);}→ TypeScript (TsEnumStyle.Enum):
export enum OrderStatus { Pending = "Pending", Shipped = "Shipped", Delivered = "Delivered",}→ Python ((str, Enum) idiom — portable across 3.8+; StrEnum arrived in 3.11):
from enum import Enum
class OrderStatus(str, Enum): PENDING = "Pending" SHIPPED = "Shipped" DELIVERED = "Delivered"Without the converter the defaults stay — numeric TS enum (unaffected by EnumStyle since numeric unions of integer literals aren’t useful), IntEnum in Python.
OpenAPI always emits type: string, enum: [...] because that’s what the OpenAPI
ecosystem expects; numeric-enum integer discriminators are rarer and use
$ref or explicit type: integer overrides instead.
Non-standard converters (custom JsonConverter<T> subclasses) don’t flip the
flag — TypeGen doesn’t guess their serialised shape, so members still emit as
integers. Use [TsType] / [OpenApiProperty] to override per property.
Transitive discovery of nested types
Section titled “Transitive discovery of nested types”[GenerateTypes] only needs to go on root types — the generator walks every
public property recursively and pulls in any user-defined class, record, struct
or enum it finds, inheriting Targets and OutputDir from whichever root
reached it. Without this the reference types would fall through to unknown
in TS / type: object in OpenAPI, producing output that doesn’t type-check.
[GenerateTypes(Targets = TypeTarget.TypeScript, OutputDir = "../client/src/api")]public class Order{ public Customer Buyer { get; set; } = new(); // ← Customer has NO [GenerateTypes] public List<LineItem> Lines { get; set; } = new(); public OrderStatus Status { get; set; }}
public class Customer { public string Name { get; set; } = ""; }public class LineItem { public int Qty { get; set; } public Product Item { get; set; } = new(); }public class Product { public string Sku { get; set; } = ""; }public enum OrderStatus { Pending, Shipped }All of Customer, LineItem, Product, OrderStatus get emitted into
../client/src/api/ alongside Order.ts, with the right cross-file imports
(import { Customer } from './Customer'; etc.).
What counts as “user-defined”:
- Declared in the current compilation’s own assembly (not NuGet packages)
- Namespace isn’t
System.*,Microsoft.*, orNewtonsoft.* - Class, record, struct, or enum — primitives (
int,string,Guid,DateTime,decimal, etc.) stay mapped to TS primitives / OpenAPI scalars
Collection unwrapping: List<T>, T[], IEnumerable<T>, HashSet<T>,
Dictionary<K, V> and the common readonly / interface variants are walked
transparently — the generator discovers T (and V for dictionaries) without
you writing anything extra.
Cycles (Node.Parent : Node?, A→B→A) terminate via a visited-set keyed
by symbol identity — each type is emitted exactly once.
Overrides win over discovery. Attributes and fluent config on a discovered type still apply:
// Attribute on a nested class — still honored.[TsName("BuyerDto")]public class Customer { ... }
// Fluent on a discovered type — also honored. The fluent pass runs again// after discovery, so freshly-pulled-in types go through the same merge.b.ForType<Customer>() .TsName("BuyerDto") .Property(c => c.Name).TsName("displayName");If a discovered type happens to carry its own [GenerateTypes] attribute with
different Targets / OutputDir, that explicit configuration wins — discovery
never overwrites it.
Opt out with [TsIgnore] / [OpenApiIgnore] on the nested class, or
[TsIgnore] on the property itself (skips the reference entirely so the type
isn’t walked through that path).
Dictionaries, inheritance
Section titled “Dictionaries, inheritance”Dictionary<string, V>(andIDictionary/IReadOnlyDictionary) emits as{ type: object, additionalProperties: <V-schema> }. Non-string keys are tolerated but key typing isn’t preserved — OpenAPI only supports string keys.
Inheritance — structure preserved, not flattened
Section titled “Inheritance — structure preserved, not flattened”The emitted TS / OpenAPI mirrors the C# inheritance chain 1:1. Every base
class becomes its own schema, each level owns only its declared members, and
extends / allOf wires the hierarchy together. Un-annotated bases get
auto-seeded into the model with the descendant’s Targets + OutputDir:
public class Entity { public int Id { get; set; } }public class Timestamped : Entity { public DateTime CreatedAt { get; set; } }public class Auditable : Timestamped { public string CreatedBy { get; set; } = ""; }
[GenerateTypes(Targets = TypeTarget.TypeScript, OutputDir = "generated")]public class Order : Auditable { public string Customer { get; set; } = ""; }→ four TS files, each owning only its declared members:
// Entity.ts → interface Entity { id: number; }// Timestamped.ts → interface Timestamped extends Entity { createdAt: string; }// Auditable.ts → interface Auditable extends Timestamped { createdBy: string; }// Order.ts → interface Order extends Auditable { customer: string; }Mixed [GenerateTypes] on some levels works the same way — any class already
in the model stays as-is, un-annotated intermediates are auto-seeded. No
duplication anywhere: a member declared on Entity appears only in Entity.ts,
not copied into every descendant.
Flattening still happens when the base can’t stand on its own — generic
bases (Foo<T>, out of MVP scope per TG0003) and BCL types have their
declared members inlined into the nearest emittable descendant so properties
aren’t lost. Everything else preserves the chain.
Abstract overrides. When an abstract member on a non-emittable ancestor is overridden by an intermediate in the chain, the override wins — the member lands on the class that declared the concrete body, once, via standard name-keyed dedupe.