SimpleTypeScript.TypeGeneration 1.1.0

dotnet add package SimpleTypeScript.TypeGeneration --version 1.1.0
                    
NuGet\Install-Package SimpleTypeScript.TypeGeneration -Version 1.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SimpleTypeScript.TypeGeneration" Version="1.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SimpleTypeScript.TypeGeneration" Version="1.1.0" />
                    
Directory.Packages.props
<PackageReference Include="SimpleTypeScript.TypeGeneration" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SimpleTypeScript.TypeGeneration --version 1.1.0
                    
#r "nuget: SimpleTypeScript.TypeGeneration, 1.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SimpleTypeScript.TypeGeneration@1.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SimpleTypeScript.TypeGeneration&version=1.1.0
                    
Install as a Cake Addin
#tool nuget:?package=SimpleTypeScript.TypeGeneration&version=1.1.0
                    
Install as a Cake Tool

SimpleTypeScript.TypeGeneration πŸ“˜

Platform NuGet License: MIT

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}");
}
  • ModuleCatalog discovers 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.
  • ModuleWriter creates the directory a module names, and empties it first for a module that OwnsDirectory β€” a file the generator has stopped producing otherwise stays importable, and a stale shape is the one nobody notices.
  • GeneratedHeader writes the do-not-edit banner. The default names the entry assembly, so a project renamed or moved takes its header with it; GeneratedHeader.None writes 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 on InnerException.

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 IncludeFields and [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 β€” WhenWriting and WhenReading β€” are read that way round, which is backwards for a payload the consumer builds rather than receives.
  • 64-bit precision. long, ulong and decimal are number, 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 a Mappings entry.

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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.1.0 79 8/20/2026
1.0.0 215 8/3/2026
0.5.0 113 8/3/2026