Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

TypeScript for C# Engineers: The Type System Compared

For .NET engineers who know: C# generics, interfaces, nullable reference types, and the nominal type system You’ll learn: How TypeScript’s structural type system differs from C#’s nominal one, and how to apply the discipline required to make it safe Time: 15-20 min read


The .NET Way (What You Already Know)

C#’s type system is nominal. The type’s name is its identity. If you define two classes with identical properties, they are not interchangeable — the compiler enforces the distinction.

public class Dog
{
    public string Name { get; set; }
    public int Age { get; set; }
}

public class Cat
{
    public string Name { get; set; }
    public int Age { get; set; }
}

// This does not compile. Dog and Cat are different types,
// regardless of their identical shape.
Dog myPet = new Cat { Name = "Whiskers", Age = 3 }; // CS0029

This is the guarantee that makes C# refactoring reliable. When you rename a type, the compiler finds every violation. The type system tracks what something is, not merely what it looks like.

Nullable reference types (introduced in C# 8.0, enforced by default from C# 10.0 with <Nullable>enable</Nullable>) extend this guarantee: the compiler can prove at compile time whether a variable can be null, and it forces you to handle that case explicitly.

string name = null;           // CS8600: Converting null literal to non-nullable type
string? nullable = null;      // Fine — you've declared the intent
int length = nullable.Length; // CS8602: Dereference of possibly null reference
int safeLength = nullable?.Length ?? 0; // Correct

Keep this mental model in mind. TypeScript’s type system solves the same problems but makes different trade-offs, and several of them will surprise you.


The TypeScript Way

Structural Typing: Shape Over Name

TypeScript’s type system is structural. Compatibility is determined by shape, not by the type’s declared name.

// TypeScript
interface Dog {
  name: string;
  age: number;
}

interface Cat {
  name: string;
  age: number;
}

// This is valid TypeScript. Dog and Cat have the same shape.
const myDog: Dog = { name: "Rex", age: 4 };
const myCat: Cat = myDog; // No error. Shape matches.

Side by side:

ScenarioC#TypeScript
Two types with same propertiesIncompatible (nominal)Compatible (structural)
A subclass passed as base typeCompatible (inheritance)Compatible if shape is a superset
A plain object literal typed as an interfaceRequires new ClassName()Object literal is directly assignable
Type identity checked at runtimeYes (is, typeof, GetType())No — types are erased at runtime

Structural typing is powerful — it lets you model duck-typed JS naturally. But it means the compiler will accept assignments you might not intend. Discipline fills that gap.


Primitive Types: The Mapping

C# has a rich set of numeric types. TypeScript has one: number. This is JavaScript’s IEEE 754 double-precision float underneath.

C# typeTypeScript equivalentNotes
stringstringSame semantics
boolbooleanDifferent name
int, long, shortnumberAll the same at runtime
double, floatnumberNo distinction
decimalnumberLoss of precision — see Gotchas
charstring (length 1)No dedicated char type
bytenumberNo dedicated byte type
objectobject or unknownPrefer unknown — see below
voidvoidSame concept
nullnullExplicit null literal
GuidstringConvention: UUID strings
DateTime, DateTimeOffsetstring or DateSee Gotchas

The number collapse is the biggest practical difference. If your C# code distinguishes between int counts and decimal currency amounts, TypeScript won’t enforce that for you. You’ll need either branded types (see Article 2.6) or Zod schemas (see Article 2.3) to enforce the distinction at runtime.

// TypeScript: all of these are `number`
const count: number = 42;
const price: number = 9.99;
const ratio: number = 0.001;

// C# equivalent:
// int count = 42;
// decimal price = 9.99m;
// double ratio = 0.001;

interface vs. type: The Opinionated Answer

TypeScript has two constructs for defining shapes: interface and type. This causes more unnecessary debate than it deserves. Here is the practical guidance:

Use interface for:

  • Object shapes (API responses, DTOs, component props, domain models)
  • Anything that other interfaces or classes might extend or implement

Use type for:

  • Union types (type Status = 'active' | 'inactive')
  • Intersection types (type AdminUser = User & Admin)
  • Mapped and conditional types
  • Aliases for primitives or tuples

The most important distinction: interfaces are open (they can be re-declared and merged across modules). Types are closed (one declaration, no merging). In practice, this means library authors prefer interfaces because consumers can extend them; application code can use either.

// Interface — open, extensible, preferred for object shapes
interface User {
  id: string;
  email: string;
}

// This declaration merges with the one above — works with interface, fails with type
interface User {
  displayName: string;
}
// Result: User now has id, email, and displayName

// Type alias — closed, preferred for unions and computed shapes
type Status = 'pending' | 'active' | 'suspended';
type NullableString = string | null;
type UserOrAdmin = User | AdminUser;

// Extending an interface (like C# interface inheritance)
interface AdminUser extends User {
  role: 'admin';
  permissions: string[];
}

C# comparison:

// C# interface — also used for object shapes and contracts
public interface IUser
{
    string Id { get; }
    string Email { get; }
}

// C# doesn't have union types — you'd use inheritance, discriminated unions,
// or a OneOf library to approximate this.

Classes in TypeScript vs. C#

TypeScript classes are syntactic sugar over JavaScript’s prototype chain. They look like C# classes but behave differently in important ways.

// TypeScript
class UserService {
  private readonly baseUrl: string;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  // Shorthand: parameter properties (no C# equivalent — saves the assignment)
  // constructor(private readonly baseUrl: string) {}

  async getUser(id: string): Promise<User> {
    const response = await fetch(`${this.baseUrl}/users/${id}`);
    return response.json() as User;
  }
}
// C#
public class UserService
{
    private readonly string _baseUrl;

    public UserService(string baseUrl)
    {
        _baseUrl = baseUrl;
    }

    public async Task<User> GetUser(string id)
    {
        using var client = new HttpClient();
        var response = await client.GetFromJsonAsync<User>($"{_baseUrl}/users/{id}");
        return response!;
    }
}

Access modifiers in TypeScript:

C# modifierTypeScript equivalentNotes
publicpublic (default)Default in TS; also default in C# for interface members
privateprivateTypeScript private is compile-time only — see Gotchas
protectedprotectedSame semantics
internalNo equivalentNo module-level visibility like C# internal
private protectedNo equivalent
readonlyreadonlySame semantics for properties

TypeScript also has # (ES private fields), which are enforced at runtime:

class Counter {
  #count = 0;           // True private — inaccessible at runtime too
  private legacy = 0;   // Compile-time only — accessible at runtime via any

  increment() {
    this.#count++;
    this.legacy++;
  }
}

const c = new Counter();
// (c as any).legacy     // Works at runtime — TypeScript's `private` is erased
// (c as any)['#count']  // Does NOT work — # fields are truly private

Parameter properties are a TypeScript shorthand worth knowing. Instead of declaring a field, declaring a constructor parameter, and assigning one to the other (the C# way), TypeScript lets you do it in one place:

class ProductService {
  // This single line: declares the field AND assigns it from the constructor parameter
  constructor(
    private readonly db: Database,
    private readonly cache: CacheService
  ) {}
}

Generics: Familiar Territory

Generics in TypeScript map closely to C# generics in syntax and intent. The differences are mostly about what constraints you can express.

// TypeScript generic function
function first<T>(items: T[]): T | undefined {
  return items[0];
}

// Generic interface
interface Repository<T> {
  findById(id: string): Promise<T | null>;
  save(entity: T): Promise<T>;
  delete(id: string): Promise<void>;
}

// Generic with constraint
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

// Constraint: T must have an `id` property
interface HasId {
  id: string;
}

function findById<T extends HasId>(items: T[], id: string): T | undefined {
  return items.find(item => item.id === id);
}
// C# generics for comparison
T? First<T>(IEnumerable<T> items) => items.FirstOrDefault();

interface IRepository<T>
{
    Task<T?> FindById(string id);
    Task<T> Save(T entity);
    Task Delete(string id);
}

// Constraint: T must implement IHasId
T? FindById<T>(IEnumerable<T> items, string id) where T : IHasId
{
    return items.FirstOrDefault(x => x.Id == id);
}

Where TypeScript generics diverge from C#:

  • TypeScript uses extends for constraints (not where)
  • keyof T is a TypeScript-only concept — it produces a union of string literal types representing the property names of T
  • TypeScript’s generics are purely structural: T extends HasId means “T’s shape must include the id: string property,” not “T must be declared as implementing HasId”
  • No runtime generic type information (unlike C# where typeof(T) and reflection work at runtime)

Enums: The Trap

TypeScript has enums, and they look familiar to C# engineers. Resist the temptation to use them freely. TypeScript enums have several design problems.

The problem with numeric enums:

enum Direction {
  North,  // 0
  South,  // 1
  East,   // 2
  West    // 3
}

// These are all valid TypeScript — no error:
const d: Direction = 100;  // Any number is assignable to a numeric enum
const d2: Direction = 0 | 1; // Bitwise ops produce numbers, all accepted

The problem with string enums:

enum Status {
  Active = 'ACTIVE',
  Inactive = 'INACTIVE',
}

// String enums are nominal — they don't accept plain strings:
const s: Status = 'ACTIVE'; // Error: Type '"ACTIVE"' is not assignable to type 'Status'
// You're forced to use Status.Active everywhere — adds friction, no real safety

The compiled output problem:

TypeScript is supposed to be a type layer over JavaScript — types are erased at compile time. Enums break this rule: they compile to a JavaScript object (an IIFE), which means your runtime bundle includes enum code even though TypeScript types don’t exist at runtime. const enum avoids this by inlining values, but it has its own issues with module boundaries.

What to use instead:

// Option 1: const object + typeof — the recommended pattern
const Direction = {
  North: 'north',
  South: 'south',
  East: 'east',
  West: 'west',
} as const;

type Direction = typeof Direction[keyof typeof Direction];
// Direction is now: 'north' | 'south' | 'east' | 'west'

function move(dir: Direction) { /* ... */ }
move('north');           // Works
move(Direction.North);   // Works
move('diagonal');        // Error — not in the union

// Option 2: string union literal types — the simplest approach
type Status = 'active' | 'inactive' | 'suspended';

// Option 3: When you need an enum-like object with iteration capability
const HttpStatus = {
  Ok: 200,
  Created: 201,
  BadRequest: 400,
  NotFound: 404,
  InternalServerError: 500,
} as const;

type HttpStatus = typeof HttpStatus[keyof typeof HttpStatus];

The as const assertion is the key: it tells TypeScript to infer the narrowest possible types (literal types like 'north', not string) and makes all properties readonly.


null and undefined: Two Nothings

C# has one null. TypeScript has two: null and undefined. They are distinct types with distinct semantics.

  • null — an intentional absence of a value. Explicitly assigned.
  • undefined — an uninitialized value. The default state in JavaScript when a variable is declared but not assigned, when an object property doesn’t exist, or when a function returns without a value.
// Both are distinct
let a: string | null = null;       // Intentional null
let b: string | undefined;         // Uninitialized — value is `undefined`
let c: string | null | undefined;  // Could be either

// Optional properties use undefined, not null
interface Config {
  baseUrl: string;
  timeout?: number;    // Same as: timeout: number | undefined
  apiKey?: string;
}

// Optional function parameters
function connect(url: string, timeout?: number): void {
  const t = timeout ?? 5000; // Nullish coalescing — same as C# ??
}

connect('https://api.example.com');         // timeout is undefined
connect('https://api.example.com', 3000);   // timeout is 3000

C# comparison:

// C# — one null to rule them all
string? name = null;
int? timeout = null;

// C# doesn't distinguish "intentional null" from "not provided"
// Optional parameters use default values instead
void Connect(string url, int timeout = 5000) { }

The strict null checks flag (strictNullChecks) is what makes TypeScript’s type system safe for nullability. With it enabled, null and undefined are not assignable to other types unless you explicitly declare them. Without it, TypeScript’s nullability is essentially C# without nullable reference types — a false sense of security.

Always enable strictNullChecks. It’s included in strict: true (more on that below).

Non-null assertion operator:

// The ! operator — tells TypeScript "I know this isn't null"
// Equivalent to C#'s null-forgiving operator (!) from C# 8.0
const input = document.getElementById('email')!; // Tell TS: this exists
const value = input.value; // No null error

// Use sparingly — it's lying to the type system if you're wrong

any, unknown, and never

These three types have no clean C# equivalents and are worth understanding precisely.

any — the type system opt-out:

let x: any = 'hello';
x = 42;           // Fine
x = true;         // Fine
x.nonExistent();  // No error — you've turned off type checking for x
const y: string = x; // Fine — any is assignable to anything

// Avoid any. It's contagious — it spreads through your codebase.
// The only legitimate uses: interfacing with untyped JS libraries
// before types are available, or rapid prototyping.

unknown — the safe alternative to any:

let x: unknown = getExternalData();

// Unlike any, you can't use unknown without narrowing first:
x.toUpperCase();         // Error: Object is of type 'unknown'
const y: string = x;    // Error: Type 'unknown' not assignable to 'string'

// You must narrow it first:
if (typeof x === 'string') {
  x.toUpperCase(); // Fine inside the narrowed block
}

// Or use a type assertion (risky — you're responsible):
const s = x as string;

unknown is what you should use when you genuinely don’t know the type upfront — when parsing JSON from an external source, for example. It forces you to verify before using, rather than silently propagating an unchecked assumption.

never — the bottom type:

never is the type for values that can never exist. It’s the TypeScript equivalent of void in C# for functions that throw unconditionally, but it’s also used in exhaustiveness checking.

// A function that never returns (always throws or loops forever)
function fail(message: string): never {
  throw new Error(message);
}

// Exhaustiveness checking — the most useful application
type Shape = 'circle' | 'square' | 'triangle';

function area(shape: Shape): number {
  switch (shape) {
    case 'circle': return Math.PI * 5 * 5;
    case 'square': return 25;
    case 'triangle': return 12.5;
    default:
      // If you add a new Shape variant and forget to handle it,
      // the compiler will flag this assignment — shape would be `never`
      // only if all cases are exhausted
      const _exhaustive: never = shape;
      throw new Error(`Unhandled shape: ${shape}`);
  }
}

This exhaustiveness pattern is the TypeScript equivalent of C#’s pattern matching exhaustiveness in switch expressions.


The strict Compiler Flag Family

The TypeScript compiler has a collection of strictness flags. The strict: true setting in tsconfig.json enables all of them. You should always start with strict: true and work from there. Disabling individual flags is an escape hatch for migrating legacy code, not a permanent configuration.

{
  "compilerOptions": {
    "strict": true
  }
}

strict: true enables:

FlagWhat it enforces
strictNullChecksnull and undefined must be explicitly declared
strictFunctionTypesStricter function parameter type checking
strictBindCallApplyTyped bind, call, and apply
strictPropertyInitializationClass properties must be initialized in the constructor
noImplicitAnyVariables without inferred types must have explicit types
noImplicitThisthis must have an explicit type in function bodies
alwaysStrictEmits 'use strict' in all compiled files

Additional flags worth considering beyond strict:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,  // array[i] returns T | undefined, not T
    "exactOptionalPropertyTypes": true, // undefined must be explicit in optional props
    "noImplicitReturns": true,          // All code paths must return a value
    "noFallthroughCasesInSwitch": true  // No accidental switch case fallthrough
  }
}

noUncheckedIndexedAccess is particularly valuable for .NET engineers: it makes array indexing honest. In C#, items[0] throws at runtime if the array is empty. In TypeScript without this flag, items[0] has type T even though it might be undefined. With this flag, items[0] has type T | undefined, and you’re forced to check.


Key Differences

ConceptC#TypeScript
Type systemNominal (name-based identity)Structural (shape-based identity)
Numeric typesint, long, decimal, double, floatnumber (all are the same)
NullabilityNullable<T> / T? with NRTsT | null, T | undefined, or T? on optional properties
Two kinds of nullNoYes — null and undefined are distinct
EnumsSafe, nominal, no spurious assignabilityProblematic — prefer const objects or union types
Generic constraintswhere T : IInterface, new()T extends Shape (structural)
Type information at runtimeYes (reflection)No — types are erased at compilation
Type safety opt-outdynamicany
Type safety opt-in for unknownsobjectunknown
ExhaustivenessRoslyn warning in switch expressionsnever + explicit checks
Access modifiersNominal enforcement at runtime (IL)private is compile-time only; # is runtime
Classes vs. interfacesInterfaces are pure abstractionsInterfaces are compile-time only; both erased

Gotchas for .NET Engineers

1. private is not private at runtime

TypeScript’s private keyword is enforced only by the compiler. At runtime, after compilation to JavaScript, all properties are accessible. This matters for security, serialization, and interop.

class ApiClient {
  private apiKey: string;

  constructor(apiKey: string) {
    this.apiKey = apiKey;
  }
}

const client = new ApiClient('secret-key-123');
console.log((client as any).apiKey); // Prints: secret-key-123

// If you need actual runtime privacy, use # (ES private fields):
class SafeClient {
  #apiKey: string;

  constructor(apiKey: string) {
    this.#apiKey = apiKey;
  }
}

const safe = new SafeClient('secret-key-123');
console.log((safe as any)['#apiKey']); // undefined — truly inaccessible

The implication: never use TypeScript’s private to hide sensitive data. It’s a development-time contract, not a security mechanism.

2. number cannot represent decimal safely

C# decimal is a 128-bit type designed for financial arithmetic. TypeScript’s number is a 64-bit IEEE 754 float — the same type C# uses for double. Financial calculations in TypeScript are dangerous without an arbitrary precision library.

// This produces the wrong result — classic floating point issue
console.log(0.1 + 0.2); // 0.30000000000000004

// C# decimal avoids this:
// decimal a = 0.1m; decimal b = 0.2m; // 0.1 + 0.2 = 0.3 exactly

// For financial values in TypeScript, use a library:
// - dinero.js (recommended for currency)
// - decimal.js or big.js (arbitrary precision)
import Dinero from 'dinero.js';
const price = Dinero({ amount: 1099, currency: 'USD' }); // $10.99 in cents
const tax = price.percentage(8);
const total = price.add(tax);

If your .NET codebase uses decimal anywhere that matters financially, plan for this when bringing that logic to TypeScript.

3. Type assertions (as) are not casts — they are lies the compiler accepts

In C#, a cast throws InvalidCastException at runtime if the types are incompatible. TypeScript’s as operator tells the compiler to trust you — it performs no runtime check whatsoever.

// This compiles fine and produces a corrupt object silently:
const user = JSON.parse(apiResponse) as User;
// user looks like User to TypeScript — but if the API returns unexpected data,
// you won't find out until runtime when you access user.email and it's undefined

// C# equivalent would throw at deserialization:
// var user = JsonSerializer.Deserialize<User>(apiResponse);
// Missing required fields throw an exception

// The correct TypeScript approach: validate at the boundary with Zod
import { z } from 'zod';

const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  displayName: z.string(),
});

// This throws a descriptive error if the shape doesn't match:
const user = UserSchema.parse(JSON.parse(apiResponse));

See Article 2.3 for the full runtime validation story with Zod.

4. Structural typing allows unintended assignments across domain types

This is the most significant discipline gap relative to C#. In C#, you can’t pass an OrderId where a UserId is expected even if both are Guid. In TypeScript with structural typing, you can pass a User where an Order is expected if their shapes happen to match.

interface UserId {
  value: string;
}

interface OrderId {
  value: string;
}

// Identical shapes — TypeScript considers them compatible:
const userId: UserId = { value: 'user-123' };
const orderId: OrderId = userId; // No error. This is a bug in the making.

function deleteOrder(id: OrderId): void { /* ... */ }
deleteOrder(userId); // TypeScript accepts this

The solution is branded types, covered in Article 2.6:

type UserId = string & { readonly _brand: 'UserId' };
type OrderId = string & { readonly _brand: 'OrderId' };

function createUserId(value: string): UserId {
  return value as UserId;
}

function deleteOrder(id: OrderId): void { /* ... */ }
const userId = createUserId('user-123');
deleteOrder(userId); // Now this correctly errors

5. TypeScript enums produce JavaScript output — and numeric enums have spurious assignability

As covered above, numeric enums accept any number. This is a known design issue. The TypeScript team has acknowledged it. The practical solution is to avoid numeric enums entirely and use the const object pattern.

// Broken — any number is assignable
enum Priority { Low = 1, Medium = 2, High = 3 }
const p: Priority = 9999; // No error

// Correct — only the declared values are valid
const Priority = { Low: 1, Medium: 2, High: 3 } as const;
type Priority = typeof Priority[keyof typeof Priority]; // 1 | 2 | 3
const p: Priority = 9999; // Error: Type '9999' is not assignable to type '1 | 2 | 3'

6. Date serialization between TypeScript and other backends

TypeScript’s Date object is a thin wrapper around a Unix timestamp. When you serialize it to JSON (JSON.stringify), it produces an ISO 8601 string. When you deserialize JSON, JSON.parse does NOT automatically convert ISO strings back to Date objects — they remain strings.

const event = { name: 'Launch', date: new Date('2026-03-01') };
const json = JSON.stringify(event);
// '{"name":"Launch","date":"2026-03-01T00:00:00.000Z"}'

const parsed = JSON.parse(json);
parsed.date instanceof Date; // false — it's a string
typeof parsed.date;          // 'string'

// You must explicitly convert, or use Zod's z.coerce.date():
import { z } from 'zod';
const EventSchema = z.object({
  name: z.string(),
  date: z.coerce.date(), // Converts ISO string to Date
});

This is especially relevant when consuming .NET or Python APIs, where date serialization formats can differ. Map dates at the boundary, not deep in your application code.


Hands-On Exercise

You have a C# domain model for an invoice system. Your task is to translate it to TypeScript using the patterns from this article.

The C# source:

public enum InvoiceStatus
{
    Draft,
    Sent,
    Paid,
    Overdue,
    Cancelled
}

public class LineItem
{
    public Guid Id { get; init; }
    public string Description { get; init; }
    public decimal UnitPrice { get; init; }
    public int Quantity { get; init; }
    public decimal Total => UnitPrice * Quantity;
}

public class Invoice
{
    public Guid Id { get; init; }
    public string InvoiceNumber { get; init; }
    public Guid CustomerId { get; init; }
    public InvoiceStatus Status { get; init; }
    public DateTimeOffset IssuedAt { get; init; }
    public DateTimeOffset? DueDate { get; init; }
    public IReadOnlyList<LineItem> LineItems { get; init; }
    public decimal? Notes { get; init; }
}

public interface IInvoiceRepository
{
    Task<Invoice?> GetById(Guid id);
    Task<IReadOnlyList<Invoice>> GetByCustomer(Guid customerId, InvoiceStatus? status = null);
    Task<Invoice> Save(Invoice invoice);
}

Your task:

  1. Convert InvoiceStatus to a const object + union type (not a TypeScript enum).
  2. Define LineItem and Invoice as TypeScript interfaces. Use readonly properties throughout.
  3. Add a computed total property to LineItem that TypeScript can express structurally.
  4. Decide: where does decimal precision matter here, and how would you note the risk in your types?
  5. Convert IInvoiceRepository to a TypeScript interface with correct return types (use Promise<T>, not Task<T>).
  6. Add proper null vs. undefined semantics — where is something intentionally absent vs. not yet provided?
  7. Enable strict: true in a minimal tsconfig.json and verify your types compile.

Stretch goal: Add a branded type for InvoiceId and CustomerId so they cannot be accidentally swapped.


Quick Reference

C#TypeScriptNotes
int, long, double, floatnumberAll map to the same runtime type
decimalnumber (risky)Use dinero.js or decimal.js for money
stringstringIdentical semantics
boolbooleanDifferent name
charstringNo char type in TS
GuidstringConvention only — no UUID type
DateTimestring (ISO) or DateSerialize/deserialize manually or via Zod
T? / Nullable<T>T | nullExplicit nullability
Optional parameterparam?: T (= T | undefined)undefined, not null
objectunknownUse unknown, not any
dynamicanyBoth disable type checking; both dangerous
void (throws)neverBottom type — function never returns
interface IFoointerface FooTS omits the I prefix convention
public class Fooclass FooAccess modifiers work similarly
private field#field for real privacyprivate keyword is compile-time only
readonly propertyreadonly propertySame semantics
where T : IShapeT extends ShapeStructural constraint, not nominal
typeof(T)Not availableTypes are erased at runtime
is type checktypeof, instanceof, type guardsUser-defined type guards for complex checks
enum Direction { North }const Direction = { North: 'north' } as constAvoid TS enums — use const objects
Nullable<T> + NRTs enabledstrict: true in tsconfigBoth require opt-in; both are mandatory
JsonSerializer.Deserialize<T>()Schema.parse(JSON.parse(s))Zod validates; as T does not

tsconfig.json minimum for a new project:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist"
  }
}

Further Reading