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.
- Paste the DTOs, every class, record and enum the API returns, into the converter below.
- Read "Needs your judgment" first: a
longid or a type you forgot to paste is listed there. - Decide the enum policy once: numbers (the default) or
JsonStringEnumConverteron the API andenum=stringon the converter. - Decide what a missing value looks like:
null(the default) or an absent key withWhenWritingNullandnullable=optional. - Copy the output into one
.tsfile, type-check it withtsc --noEmit --strict, and regenerate it through the API whenever a DTO changes.
Try it on your own types
Needs your judgment
Why it looks like this
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.