SimpleTypeScript.TypeGeneration
1.1.0
dotnet add package SimpleTypeScript.TypeGeneration --version 1.1.0
NuGet\Install-Package SimpleTypeScript.TypeGeneration -Version 1.1.0
<PackageReference Include="SimpleTypeScript.TypeGeneration" Version="1.1.0" />
<PackageVersion Include="SimpleTypeScript.TypeGeneration" Version="1.1.0" />
<PackageReference Include="SimpleTypeScript.TypeGeneration" />
paket add SimpleTypeScript.TypeGeneration --version 1.1.0
#r "nuget: SimpleTypeScript.TypeGeneration, 1.1.0"
#:package SimpleTypeScript.TypeGeneration@1.1.0
#addin nuget:?package=SimpleTypeScript.TypeGeneration&version=1.1.0
#tool nuget:?package=SimpleTypeScript.TypeGeneration&version=1.1.0
SimpleTypeScript.TypeGeneration π
Generate TypeScript from C# types: give it roots, and it follows what their members reach β an interface per
shape, a string union per enum β written through
SimpleTypeScript.
var module = new TsModule();
new TypeWalker(new TypeWalkerOptions
{
Documentation = new XmlDocumentationSource(),
Mappings = new Dictionary<Type, TsType> { [typeof(Money)] = TsType.String },
})
.Add(typeof(Order), typeof(Customer))
.Declare(module);
File.WriteAllText(path, module.Render());
Given this C#:
/// <summary>One order, as the API returns it.</summary>
public sealed class Order
{
/// <summary>Assigned when the order is placed.</summary>
public Guid Id { get; init; }
[JsonPropertyName("ref")]
public string Reference { get; init; } = string.Empty;
[JsonIgnore]
public string InternalNote { get; init; } = string.Empty;
public DateTimeOffset? ShippedAt { get; init; }
public OrderStatus Status { get; init; }
public IReadOnlyList<Line> Lines { get; init; } = [];
}
it writes this:
/** One order, as the API returns it. */
export interface Order {
/** Assigned when the order is placed. */
id: string;
ref: string;
shippedAt: string | null;
status: OrderStatus;
lines: Line[];
}
export type OrderStatus = "Open" | "Shipped";
It reads the shape that is serialized, not only the shape that is declared. [JsonIgnore] drops a
member β or makes it optional, where its condition says the producer merely omits it sometimes β
[JsonPropertyName] renames it, [JsonExtensionData] is not a member at all, and everything else takes the
naming policy: camel case by default, matching what a JSON API is usually configured with. A generator that
reads the C# alone spells every member wrong the moment a policy is set, and nothing says so until a field is
undefined.
What it decides, and how to overrule it
TypeWalkerOptions |
||
|---|---|---|
| A member name | camel case | MemberNamingPolicy, or null for the C# name |
| A nullable member | T \| null, for a nullable reference and a Nullable<T> alike |
β |
| A nullable element | the same, read per position: string?[] is (string \| null)[] and string[]? is string[] \| null |
β |
| A member the producer omits | ?, and no \| null β a key that is absent never arrives holding one |
DefaultIgnoreCondition, or the [JsonIgnore] condition on the member |
| A member the API requires | never optional; required, [JsonRequired] and [Required] all say so |
β |
| An enum | a union of the member names, which is what JsonStringEnumConverter writes |
EnumStyle (below) and EnumNamingPolicy where a converter renames the members |
| A sequence | T[]; a dictionary is Record<string, V>, since a JSON key is a string whatever the C# key is |
β |
| A BCL type | TypeMappings.Default: dates, Guid, Uri, every numeric, byte[] as its base64 string, JsonElement and the JsonNode family as unknown |
Mappings, merged over the defaults |
| Any other platform type | refused: nothing under System. or Microsoft. is a payload, so writing one would describe an implementation |
Mappings |
| Two types of one name | refused: the output has no namespaces to tell them apart with | Name |
| Anything else | refused rather than written as any |
Mappings |
| A member's mutability | mutable, as a generated type is everywhere else | ReadOnlyMembers, which is shallow β see below |
| A declaration's name | the C# type name | Name |
| Doc comments | none | Documentation; XmlDocumentationSource reads the compiler's XmlDoc and flattens its markup to a sentence |
A mapped type is a leaf: nothing behind it is reached, which makes mapping the fix for a framework type dragging its own graph into the output, and the way to say a type is carried as something other than its shape. Declarations are emitted in name order and members in declaration order, so the file is byte-stable across runs and machines.
Absent and null are two questions
? says the key may not be there; | null says what may be in it. They are independent, and which you get
follows the producer rather than TypeScript convention β a JSON.stringify payload omits undefined, but
System.Text.Json writes "note":null unless it is configured not to.
var options = new TypeWalkerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
string? Note on a producer that⦠|
|
|---|---|
| writes every key (the serializer's default) | note: string \| null; |
omits nulls (WhenWritingNull) |
note?: string; |
omits defaults (WhenWritingDefault) |
note?: string;, and every value-type member too |
β¦but marks it required / [JsonRequired] / [Required] |
note: string \| null; β present, and still nullable |
There is no | undefined: JSON has no such literal, so a parsed payload cannot hold one. undefined only
ever arrives as a missing key, which is what ? already says.
readonly is opt-in, and shallow
ReadOnlyMembers writes readonly on every member. It is off by default β which is what a generated type
looks like in the generators that offer this at all: --immutable in openapi-typescript, immutableTypes
in graphql-codegen, [TsReadonly] per member in TypeGen. None of them makes it the default.
It is an option rather than an attribute on purpose. Nothing here declares an attribute: the walk reads
System.Text.Json's, the compiler's own required, and DataAnnotations' [Required], all of which are in
the shared framework β so the assembly holding your contracts is generated from without referencing this
one. Per-member marking would buy granularity by putting a codegen package in the dependency graph of the
types it describes.
Worth knowing what you get before asking for it:
readonly lines: Line[]; // order.lines = [] refused
// order.lines.push(x) allowed
TypeScript's readonly stops at the member. Meaning it would need the elements to be readonly too β
readonly lines: readonly Line[] β which nothing here can spell yet. So this is the shallow half: useful
where that is what you wanted, misleading if you expected the payload to be frozen.
Enums
EnumStyle picks between three. The first two are decided by what the producer serializes; the third is a
preference about what a consumer wants beside the type.
var options = new TypeWalkerOptions { EnumStyle = EnumStyle.ConstObject };
// StringUnion (default) β JsonStringEnumConverter
export type Status = "Open" | "Shipped";
// NumberUnion β an enum serialized by value; the member names cannot survive
export type Status = 0 | 1;
// ConstObject β a value to iterate, and the type read off it
export const Status = {
"Open": "Open",
"Shipped": "Shipped",
} as const;
export type Status = typeof Status[keyof typeof Status];
The const object's key is what a consumer writes and its value is what the wire carries, so
EnumNamingPolicy moves only the second: Status.Open === "open" still reads as the C# does. Object.values(Status)
is what a union alone cannot give you β a union has no run-time existence, so nothing can enumerate it.
export enum is deliberately absent. It is the one form with runtime semantics of its own, which makes it
the form a type-stripping loader β erasableSyntaxOnly, Node's own β refuses to run. An enum that should not
be generated at all is a mapping like any other type.
Generators with more than one file
A build-time tool usually writes several β the types, a vocabulary, a palette. A module says what it is, and the pipeline owns the banner, the directories and the writing:
internal sealed class ApiTypesModule : TypeModule
{
public override string FileName => "api/generated/index.ts";
public override string Source => "the wire contracts";
public override bool OwnsDirectory => true;
protected override IEnumerable<Type> Roots => [typeof(Order), typeof(Customer)];
protected override TypeWalkerOptions Options => new() { Documentation = new XmlDocumentationSource() };
}
var writer = new ModuleWriter(outputDirectory);
foreach (var module in ModuleCatalog.From())
{
var file = writer.Write(module);
Console.WriteLine($"{file.Summary} -> {file.FileName}");
}
ModuleCatalogdiscovers rather than listing β adding a generator is one class and no edit to a registry. Internal types count, and modules come back ordered by file name so a run reports the same way every time. Two modules claiming one path is a refusal, not a silent overwrite.ModuleWritercreates the directory a module names, and empties it first for a module thatOwnsDirectoryβ a file the generator has stopped producing otherwise stays importable, and a stale shape is the one nobody notices.GeneratedHeaderwrites the do-not-edit banner. The default names the entry assembly, so a project renamed or moved takes its header with it;GeneratedHeader.Nonewrites none.- Everything the pipeline can refuse is a
GenerationException, including what the emitter refuses underneath it β one thing for a host to catch and one sentence to print. Which half refused stays onInnerException.
A module that builds its declarations by hand implements IGeneratedModule directly; TypeModule is for the
common case where a module is a set of roots.
What it does not read
Named rather than discovered, because each is a shape that would otherwise generate and be quietly wrong:
- Polymorphism.
[JsonDerivedType]writes a discriminator and the derived members; the walk describes the base shape it was given and nothing else. - Fields. Only properties are read, so
IncludeFieldsand[JsonInclude]on a field do not reach the output. [JsonPropertyOrder]. Members follow declaration order, which changes the order of keys in the file and never the shape.- Imports. One module holds every declaration it needs, so nothing imports anything. A generator wanting one type per file needs a declaration kind and a file graph that are not here.
- Direction. A shape describes what the producer serializes. The two ignore conditions naming a
direction β
WhenWritingandWhenReadingβ are read that way round, which is backwards for a payload the consumer builds rather than receives. - 64-bit precision.
long,ulonganddecimalarenumber, which is what the serializer writes by default and what a JSON number is. Above 2^53 that is lossy on the other side; a producer carrying such a value as a string says so with aMappingsentry.
Installing
dotnet add package SimpleTypeScript.TypeGeneration
net8.0 and net10.0. This half reflects, which is why it is a package of its own: the emitter reflects
over nothing, and a consumer publishing NativeAOT keeps that by taking only the emitter.
The two ignore conditions naming a direction β WhenWriting and WhenReading β arrived in .NET 10, and so
did the serializer that honours them, so on net8.0 there is no producer configured that way for a shape to
describe. Nothing else differs between the two.
License
MIT. See LICENSE.txt.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- SimpleTypeScript (>= 1.1.0)
-
net8.0
- SimpleTypeScript (>= 1.1.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.