typescript csharp asp.net core system.text.json

Keep TypeScript Types in Sync with Your ASP.NET Core DTOs

Why TypeScript types should describe the JSON your API sends, not your C# classes: camelCase, enums, dates, long IDs, nullability and [JsonIgnore] — and how to generate them.

· Mohammed Aquib Ansari

The bug that finally made me generate TypeScript types from C# was a status field. The C# enum said Pending, Paid, Shipped; the front end compared order.status === 'Paid'; the API sent 1. Nobody had lied — the TypeScript type was written from the C# class, but the JSON came from System.Text.Json’s defaults, and those are not the same thing.

This guide is about closing that gap, with the C# to TypeScript converter.

Describe the JSON, not the class

Here’s a typical DTO:

public enum OrderStatus { Pending, Paid, Shipped }

public class OrderDto
{
    public Guid Id { get; set; }
    [JsonPropertyName("order_no")]
    public string OrderNumber { get; set; } = "";
    public OrderStatus Status { get; set; }
    public decimal Total { get; set; }
    public DateTime PlacedAt { get; set; }
    public List<OrderLineDto> Lines { get; set; } = new();
    public string? Notes { get; set; }
    [JsonIgnore]
    public string InternalMemo { get; set; } = "";
}

public record OrderLineDto(string Sku, int Quantity);

And here’s what the converter produces:

export type OrderStatus = 0 | 1 | 2; // Pending=0, Paid=1, Shipped=2

export interface OrderDto {
  id: string;
  order_no: string;
  status: OrderStatus;
  total: number; // decimal: values beyond 2^53 lose precision in JavaScript
  placedAt: string;
  lines: OrderLineDto[];
  notes: string | null;
}

export interface OrderLineDto {
  sku: string;
  quantity: number;
}

Every difference from a naive translation is deliberate.

Names are camelCase

ASP.NET Core’s JSON defaults (JsonSerializerDefaults.Web) camelCase property names, so PlacedAt arrives as placedAt. The converter uses the same rules as JsonNamingPolicy.CamelCase, including the edge cases — IOPort becomes ioPort, not iOPort. [JsonPropertyName("order_no")] wins over the policy, and [JsonIgnore] properties don’t appear at all, because they never reach the wire. If your API keeps PascalCase, turn camelCase off.

Enums are numbers unless you say otherwise

System.Text.Json writes enums as their numeric value by default. That’s why the converter produced 0 | 1 | 2 and a comment mapping names to values. If an enum has [JsonConverter(typeof(JsonStringEnumConverter))], it becomes a string union instead:

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum PaymentMethod { Card, BankTransfer, Wallet }
export type PaymentMethod = "Card" | "BankTransfer" | "Wallet";

Many apps register JsonStringEnumConverter globally in Program.cs instead of per enum. The converter can’t see that, so it warns you and lets you switch every enum to string unions — or to real TypeScript enums, if that’s your team’s style.

Dates and GUIDs are strings

DateTime, DateTimeOffset, DateOnly and Guid are all strings in JSON. Typing them as Date is a lie that JSON.parse won’t correct for you — you’d get a string at runtime with a Date type at compile time. The converter defaults to string. Map dates to Date only if you have a deserialization step that actually converts them.

long and decimal can lose precision

JavaScript numbers are doubles: integers are exact only up to 2^53 (9,007,199,254,740,991). A 64-bit database ID above that, or a decimal with many significant digits, gets rounded by JSON.parse before your code ever sees it. The converter maps them to number but flags each one. For IDs that can grow large, I serialize them as strings in C# ([JsonNumberHandling(JsonNumberHandling.WriteAsString)]) and type them as string.

Nullability

string?, int? and Nullable<T> become T | null, which is what the JSON carries ("notes": null). Some teams prefer optional properties (notes?: string); that’s an option too, but note that System.Text.Json writes null rather than omitting the property unless you configure DefaultIgnoreCondition = WhenWritingNull.

Records, generics and inheritance

Positional records like record OrderLineDto(string Sku, int Quantity) become interfaces with those properties. Generic wrappers keep their type parameter:

public class PagedResult<T>
{
    public IReadOnlyList<T> Items { get; init; } = [];
    public int TotalCount { get; init; }
}
export interface PagedResult<T> {
  items: T[];
  totalCount: number;
}

A base class that’s in your paste becomes extends; interfaces in the base list are skipped, since they don’t add serialized properties. Methods, constructors and fields are listed as skipped rather than silently dropped.

Keeping them in sync

For a small API, I paste the DTO folder into the converter whenever the contract changes and commit the result. For larger projects, generating from the OpenAPI document (NSwag, openapi-typescript) is the sturdier long-term setup — but the same rules apply, and it’s worth checking that your generator gets enums and camelCase right.

If you’re going the other way — you have JSON from an API and need C# classes — JSON to C# does that. And to see exactly what JSON a C# class produces, C# to JSON generates a sample payload.