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:
| Scenario | C# | TypeScript |
|---|---|---|
| Two types with same properties | Incompatible (nominal) | Compatible (structural) |
| A subclass passed as base type | Compatible (inheritance) | Compatible if shape is a superset |
| A plain object literal typed as an interface | Requires new ClassName() | Object literal is directly assignable |
| Type identity checked at runtime | Yes (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# type | TypeScript equivalent | Notes |
|---|---|---|
string | string | Same semantics |
bool | boolean | Different name |
int, long, short | number | All the same at runtime |
double, float | number | No distinction |
decimal | number | Loss of precision — see Gotchas |
char | string (length 1) | No dedicated char type |
byte | number | No dedicated byte type |
object | object or unknown | Prefer unknown — see below |
void | void | Same concept |
null | null | Explicit null literal |
Guid | string | Convention: UUID strings |
DateTime, DateTimeOffset | string or Date | See 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# modifier | TypeScript equivalent | Notes |
|---|---|---|
public | public (default) | Default in TS; also default in C# for interface members |
private | private | TypeScript private is compile-time only — see Gotchas |
protected | protected | Same semantics |
internal | No equivalent | No module-level visibility like C# internal |
private protected | No equivalent | |
readonly | readonly | Same 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
extendsfor constraints (notwhere) keyof Tis a TypeScript-only concept — it produces a union of string literal types representing the property names ofT- TypeScript’s generics are purely structural:
T extends HasIdmeans “T’s shape must include theid: stringproperty,” 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:
| Flag | What it enforces |
|---|---|
strictNullChecks | null and undefined must be explicitly declared |
strictFunctionTypes | Stricter function parameter type checking |
strictBindCallApply | Typed bind, call, and apply |
strictPropertyInitialization | Class properties must be initialized in the constructor |
noImplicitAny | Variables without inferred types must have explicit types |
noImplicitThis | this must have an explicit type in function bodies |
alwaysStrict | Emits '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
| Concept | C# | TypeScript |
|---|---|---|
| Type system | Nominal (name-based identity) | Structural (shape-based identity) |
| Numeric types | int, long, decimal, double, float… | number (all are the same) |
| Nullability | Nullable<T> / T? with NRTs | T | null, T | undefined, or T? on optional properties |
| Two kinds of null | No | Yes — null and undefined are distinct |
| Enums | Safe, nominal, no spurious assignability | Problematic — prefer const objects or union types |
| Generic constraints | where T : IInterface, new() | T extends Shape (structural) |
| Type information at runtime | Yes (reflection) | No — types are erased at compilation |
| Type safety opt-out | dynamic | any |
| Type safety opt-in for unknowns | object | unknown |
| Exhaustiveness | Roslyn warning in switch expressions | never + explicit checks |
| Access modifiers | Nominal enforcement at runtime (IL) | private is compile-time only; # is runtime |
| Classes vs. interfaces | Interfaces are pure abstractions | Interfaces 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:
- Convert
InvoiceStatusto a const object + union type (not a TypeScript enum). - Define
LineItemandInvoiceas TypeScript interfaces. Usereadonlyproperties throughout. - Add a computed
totalproperty toLineItemthat TypeScript can express structurally. - Decide: where does
decimalprecision matter here, and how would you note the risk in your types? - Convert
IInvoiceRepositoryto a TypeScript interface with correct return types (usePromise<T>, notTask<T>). - Add proper
nullvs.undefinedsemantics — where is something intentionally absent vs. not yet provided? - Enable
strict: truein a minimaltsconfig.jsonand verify your types compile.
Stretch goal: Add a branded type for InvoiceId and CustomerId so they cannot be accidentally swapped.
Quick Reference
| C# | TypeScript | Notes |
|---|---|---|
int, long, double, float | number | All map to the same runtime type |
decimal | number (risky) | Use dinero.js or decimal.js for money |
string | string | Identical semantics |
bool | boolean | Different name |
char | string | No char type in TS |
Guid | string | Convention only — no UUID type |
DateTime | string (ISO) or Date | Serialize/deserialize manually or via Zod |
T? / Nullable<T> | T | null | Explicit nullability |
| Optional parameter | param?: T (= T | undefined) | undefined, not null |
object | unknown | Use unknown, not any |
dynamic | any | Both disable type checking; both dangerous |
void (throws) | never | Bottom type — function never returns |
interface IFoo | interface Foo | TS omits the I prefix convention |
public class Foo | class Foo | Access modifiers work similarly |
private field | #field for real privacy | private keyword is compile-time only |
readonly property | readonly property | Same semantics |
where T : IShape | T extends Shape | Structural constraint, not nominal |
typeof(T) | Not available | Types are erased at runtime |
is type check | typeof, instanceof, type guards | User-defined type guards for complex checks |
enum Direction { North } | const Direction = { North: 'north' } as const | Avoid TS enums — use const objects |
Nullable<T> + NRTs enabled | strict: true in tsconfig | Both 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
- TypeScript Handbook: Type Compatibility — the official explanation of structural typing, with clear examples
- TypeScript Handbook: Everyday Types — covers primitives, unions, and interfaces without assuming JavaScript background
- TypeScript strict mode — what each flag does — the official tsconfig reference for every strict flag
- Matt Pocock’s Total TypeScript — Enums — the definitive case against TypeScript enums, from the author of the most widely used TypeScript course