A C# DTO and its TypeScript interface drift apart the moment someone renames a property, and the bug shows up as undefined in a browser, weeks later. The rules that turn one into the other are small and fixed, because System.Text.Json decides what the JSON looks like, and the JSON is the only thing the two sides share. This post shows those rules on real output, the five places where they surprise people, and a converter that applies them for you, with every mapping explained.

Everything quoted here was run on this PC: the C# was serialized with JsonSerializerDefaults.Web on .NET 10.0.13, the TypeScript came out of the converter, and the TypeScript compiler 5.9.3 checked that the JSON fits the interfaces.

Steps to a front end that matches the API
  1. Paste the DTOs, every class, record and enum the API returns, into the converter below.
  2. Read "Needs your judgment" first: a long id or a type you forgot to paste is listed there.
  3. Decide the enum policy once: numbers (the default) or JsonStringEnumConverter on the API and enum=string on the converter.
  4. Decide what a missing value looks like: null (the default) or an absent key with WhenWritingNull and nullable=optional.
  5. Copy the output into one .ts file, type-check it with tsc --noEmit --strict, and regenerate it through the API whenever a DTO changes.

Try it on your own types

Nothing is stored and nothing is compiled: the text is parsed, mapped and forgotten. The full page with the API and the complete rule list is at /tools/csharp-to-typescript.

What one DTO becomes

The example above is an appointment as a booking API would return it:

public class AppointmentDto
{
    public long Id { get; set; }
    public string PatientName { get; set; } = "";
    public string? Notes { get; set; }
    public DateTime StartsAt { get; set; }
    public AppointmentStatus Status { get; set; }
    public List<string> Tags { get; set; } = new();
    public Dictionary<string, int> VisitsByDoctor { get; set; } = new();
}

The converter wrote this, with eight notes and one warning:

/** An appointment as the booking API returns it. */
export interface AppointmentDto {
  id: number;
  patientName: string;
  notes: string | null;
  startsAt: string;
  status: AppointmentStatus;
  tags: string[];
  visitsByDoctor: Record<string, number>;
}

export enum AppointmentStatus {
  Booked = 0,
  CheckedIn = 1,
  Completed = 2,
  Cancelled = 3,
}

And this is what System.Text.Json wrote for one instance, which tsc accepted as an AppointmentDto:

{"id":9007199254740993,"patientName":"Maria Garcia","notes":null,"startsAt":"2026-10-23T09:30:00Z","status":1,"tags":["follow-up","insured"],"visitsByDoctor":{"David Chen":3,"Maria Garcia":1}}

Three things in that line are the whole story: the names are camelCase because ASP.NET Core serializes with the web defaults, the date is text, and the enum is a number. The one warning is about the id, which is trap one.

Trap 1: a long id arrives rounded

JavaScript has one number type, a 64-bit double, exact only up to 253. A C# long goes well beyond that, and System.Text.Json writes it as a plain JSON number. In the test the JSON kept 9007199254740993 exactly, and JSON.parse in a browser reads that as 9007199254740992. The interface says number because that is what the front end gets, and the converter flags every long. The cure is on the C# side:

    public long Id { get; set; }
    [JsonNumberHandling(JsonNumberHandling.WriteAsString)]
    public long ExternalId { get; set; }
  id: number;
  externalId: string;

With the attribute the value travels as a string and the interface says so. If ids never reach 253, which is true of most identity columns, the warning can be dismissed; snowflake ids and hashes cannot.

Trap 2: dates are strings, in three shapes

JSON has no date type. Every date and time type becomes string, and the shape of the text depends on the C# type and on DateTimeKind:

public class Shift
{
    public DateTime StartsAtUtc { get; set; }
    public DateTime LocalStart { get; set; }
    public DateTimeOffset BookedAt { get; set; }
    public DateOnly Day { get; set; }
    public TimeOnly OpensAt { get; set; }
    public TimeSpan Duration { get; set; }
    public DateTime? EndedAt { get; set; }
    public List<DateOnly> Holidays { get; set; } = new();
}
{"startsAtUtc":"2026-10-23T09:30:00Z","localStart":"2026-10-23T15:00:00","bookedAt":"2026-10-20T18:45:00+05:30","day":"2026-10-23","opensAt":"08:00:00","duration":"07:30:00","endedAt":null,"holidays":["2026-12-25"]}

A UTC DateTime ends in Z, an unspecified one has no offset at all, and a DateTimeOffset carries its offset; new Date() in a browser treats those three differently. DateOnly is a bare date, TimeOnly a bare time, and a TimeSpan is hh:mm:ss text, which no JavaScript date parser understands. The converter maps all of them to string and says which shape to expect; the dates=Date directive emits Date instead, with a warning, for front ends that revive dates themselves.

Trap 3: enums are numbers unless you say otherwise

Without a converter, System.Text.Json writes an enum as its number. The TypeScript side can be a numeric enum that puts the names back, or a union of strings when the API uses JsonStringEnumConverter, on the enum or on one property:

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum PaymentMethod
{
    Card,
    BankTransfer,
    [JsonStringEnumMemberName("cash-on-delivery")]
    CashOnDelivery
}

public enum Channel { Web, Phone, WalkIn }

public class Order
{
    public PaymentMethod Method { get; set; }
    [JsonConverter(typeof(JsonStringEnumConverter))]
    public Channel Channel { get; set; }
    public Channel ReturnChannel { get; set; }
}
export type PaymentMethod = "Card" | "BankTransfer" | "cash-on-delivery";

export enum Channel {
  Web = 0,
  Phone = 1,
  WalkIn = 2,
}

export interface Order {
  method: PaymentMethod;
  channel: "Web" | "Phone" | "WalkIn";
  returnChannel: Channel;
}
{"method":"cash-on-delivery","channel":"Phone","returnChannel":2}

The same C# enum, Channel, is text on one property and a number on the next, and the TypeScript follows the attribute, not the enum. The most common mistake is a global JsonStringEnumConverter registered in AddJsonOptions, which the converter cannot see in the class: tell it with // ts: enum=string on the first line.

Trap 4: null is not the same as absent

The web defaults write a null value as null; the key is always there. So a nullable C# property is T | null, not optional:

export interface PatientContact {
  id: number;
  age: number | null;
  name: string;
  email: string | null;
  lastVisit: string | null;
  phones: (string | null)[];
  homeAddress: Address | null;
  workAddress: Address;
  balances: Record<string, number | null>;
}
{"id":1,"age":null,"name":"David Chen","email":"david.chen@example.test","lastVisit":"2026-09-01T08:00:00Z","phones":["\u002B00-555-0100",null],"homeAddress":null,"workAddress":{"street":"1 Clinic Road","city":null},"balances":{"Maria Garcia":12.50,"David Chen":null}}

An API that sets DefaultIgnoreCondition = WhenWritingNull drops the key instead, and then the TypeScript must say ?, or every read of a missing key is a type error waiting to happen. That is the nullable=optional directive:

// ts: nullable=optional
namespace Samples.Optional;

public class Profile
{
    public string Name { get; set; } = "";
    public string? Bio { get; set; }
    public int? Followers { get; set; }
    public required string? Website { get; set; }
    public Uri? Avatar { get; set; }
}
export interface Profile {
  name: string;
  bio?: string;
  followers?: number;
  website?: string;
  avatar?: string;
}

The JSON for a profile with nothing filled in was {"name":"Maria Garcia"}, and tsc accepted it. Note website: it is required in C#, and the first version of the converter made it mandatory in TypeScript, which tsc rejected against that JSON. required is a rule for reading JSON, and a null required value is still left out when writing. Note also the + in the phone number above, written as \u002B by the default encoder; it is the same string once parsed.

Trap 5: dictionary keys are always strings

A JSON object has string keys and nothing else. Whatever the C# key type, the TypeScript is Record<string, V>:

    public Dictionary<string, int> ByName { get; set; } = new();
    public Dictionary<int, string> ByBed { get; set; } = new();
    public Dictionary<Guid, decimal> ByPatient { get; set; } = new();
    public Dictionary<Ward, int> ByWard { get; set; } = new();
  byName: Record<string, number>;
  byBed: Record<string, string>;
  byPatient: Record<string, number>;
  byWard: Record<string, number>;
{"byName":{"Maria Garcia":1},"byBed":{"12":"David Chen"},"byPatient":{"3f2504e0-4f89-11d3-9a0c-0305e82c3301":12.5},"byWard":{"Icu":4},"shifts":{"night":["Maria Garcia"]},"labels":{"A":"Acute"},"sorted":{"a":1,"b":2},"live":{"now":7},"frozen":{"k":1},"busiest":{"key":"Icu","value":4},"pairs":[{"key":1,"value":"one"}],"legacy":{"item1":"legacy","item2":2}}

The int key 12 became "12", and the enum key became its name, "Icu", even though enums elsewhere in the same document are numbers. A KeyValuePair is an object with key and value, and an old-style Tuple has item1 and item2; a value tuple, (int, string), is written as {} and the converter warns about it.

How the mapping was checked

Thirty-nine sample inputs cover every rule the converter has: collections, dictionaries, dates, numbers, both enum styles, [Flags], records, structs, interfaces, inheritance with a base that was not pasted, nested and generic types, the serializer attributes, polymorphism, every directive, a parse error, an empty input and a JSON sample pasted by mistake. Each emitted file went through the TypeScript compiler:

Command, once per file: npx -y -p typescript@5 tsc --noEmit --strict --target es2022 NN-name.ts

All 36 files with output passed. Then a console app built one instance of each sample type with fictional data, serialized it with the web defaults, and a generated check.ts declared each document as a constant of its interface; tsc accepted all of them except the two it was meant to reject: a Dog whose Animal base was not pasted, and a file converted with dates=Date. Both cases carry a warning in the converter's output. Roslyn 5.9.0 does the parsing, and no code is compiled or run.

Frequently asked

How do I convert a C# class to a TypeScript interface?
Map each public property by the JSON System.Text.Json writes: camelCase names, number for every numeric type, string for dates and GUIDs, T[] for every collection, Record<string, V> for dictionaries and T | null for nullable members. The converter on this page applies those rules and explains each one.
Why is my enum a number in the JSON from ASP.NET Core?
That is the System.Text.Json default: an enum is written as its integer value. Register JsonStringEnumConverter, globally in AddJsonOptions or on the enum or property, and the JSON carries the member name; then type the TypeScript side as a union of strings.
Should a nullable C# property be optional or null in TypeScript?
With the ASP.NET Core defaults the key is always present and may hold null, so write T | null. If the API sets DefaultIgnoreCondition to WhenWritingNull, the key is omitted and the property must be optional with a question mark; the converter's nullable=optional directive writes it that way.

The reverse tool, JSON → C# records, starts from a sample document instead of the classes. How a Blazor or JavaScript front end calls the API in the first place is in Calling web APIs from Blazor, and the JSON basics behind every trap here are in JSON explained for beginners.