PONY λ M2 Modula-2

C#.CodeCompared.To/TypeScript

An interactive executable cheatsheet comparing C# and TypeScript

C# 13 TypeScript 6.0
Hello World & Output
Hello, World
Console.WriteLine("Hello, World!");
console.log("Hello, World!");
TypeScript's console.log corresponds to Console.WriteLine. It accepts any number of arguments separated by spaces: console.log("count:", 42). In the browser, output goes to the DevTools console; in Node.js it goes to stdout.
String interpolation / template literals
var name = "TypeScript"; var version = 6; Console.WriteLine($"Hello from {name} {version}!");
const name = "TypeScript"; const version = 6; console.log(`Hello from ${name} ${version}!`);
TypeScript uses backtick template literals (`...${expr}...`) instead of C#'s $"...{expr}...". The result is always a string. Template literals also allow embedded newlines without escape sequences.
Console output methods
Console.WriteLine("Line with newline"); Console.Write("No newline"); Console.Write(" — same line\n"); Console.Error.WriteLine("To stderr");
console.log("Line with newline"); process.stdout.write("No newline"); process.stdout.write(" — same line\n"); console.error("To stderr");
console.log always appends a newline. For raw output without a newline, use process.stdout.write() in Node.js. console.error writes to stderr, just like Console.Error.WriteLine. console.warn and console.info also exist but map to the same streams in Node.
Variables & Types
var, let, and const
var greeting = "Hello"; // mutable, type inferred const double PI = 3.14159; // C# const requires explicit type string name = "Alice"; // explicit type Console.WriteLine($"{greeting}, {name}! Pi = {PI}");
let greeting = "Hello"; // mutable, type inferred const PI = 3.14159; // type inferred from literal const name: string = "Alice"; // explicit type annotation console.log(`${greeting}, ${name}! Pi = ${PI}`);
TypeScript has let (mutable, block-scoped), const (immutable binding — object properties are still mutable), and the legacy var (function-scoped, avoid). C#'s var corresponds to TypeScript's let with inferred type. C# const requires an explicit type and only works with compile-time constants — there is no type-inferred const for runtime values. Type annotations in TypeScript come after the variable name: const name: string.
Type annotations
int count = 42; string message = "hello"; bool active = true; double price = 9.99; Console.WriteLine($"{count} {message} {active} {price}");
const count: number = 42; const message: string = "hello"; const active: boolean = true; const price: number = 9.99; // no separate float/double/decimal type console.log(count, message, active, price);
TypeScript's primitive types are number, string, and boolean. There is no int, float, double, or decimal — all numeric values use the single number type (IEEE 754 64-bit float). Type annotations are optional when the compiler can infer the type.
Type inference
var count = 10; // int var label = "items"; // string var ratio = 0.75; // double // count = "oops"; // CS0029 — type mismatch Console.WriteLine($"{count} {label} {ratio}");
let count = 10; // inferred: number let label = "items"; // inferred: string let ratio = 0.75; // inferred: number // count = "oops"; // TS2322 — type mismatch console.log(count, label, ratio);
Both languages infer types from the right-hand side. TypeScript's inference is very powerful — it tracks the type through reassignments, function return types, and even complex object shapes. The compiler error codes differ (C# uses CS*, TypeScript uses TS*) but the semantics are nearly identical for simple cases.
any and unknown (vs C# dynamic)
dynamic value = "hello"; Console.WriteLine(value.Length); // runtime check, no compile-time error Console.WriteLine(value);
let value: any = "hello"; console.log((value as string).length); // any: no type checking at all let safer: unknown = "hello"; // console.log(safer.length); // TS error — must narrow first if (typeof safer === "string") { console.log(safer.length); // narrowed to string }
any is like C#'s dynamic — it disables type checking entirely. unknown is safer: you cannot use its value without first narrowing the type (with typeof, instanceof, or a type guard). Prefer unknown over any for values whose type you genuinely do not know.
Type aliases
using UserId = int; // C# type alias using Email = string; UserId id = 42; Email email = "alice@example.com"; Console.WriteLine($"ID {id}: {email}");
type UserId = number; // TypeScript type alias type Email = string; const id: UserId = 42; const email: Email = "alice@example.com"; console.log(`ID ${id}: ${email}`);
TypeScript's type Alias = ... creates a named alias for any type — primitives, unions, object shapes, function signatures, and more. Unlike C# using aliases (which are scoped to one file in C# 12+), TypeScript type aliases can describe complex composite types and are exported from modules.
Strings
Common string methods
var text = " Hello, World! "; Console.WriteLine(text.Trim()); Console.WriteLine(text.Trim().ToUpper()); Console.WriteLine(text.Trim().Replace("World", "TypeScript")); Console.WriteLine(text.Trim().Contains("World")); Console.WriteLine(text.Trim().StartsWith("Hello"));
const text = " Hello, World! "; console.log(text.trim()); console.log(text.trim().toUpperCase()); console.log(text.trim().replace("World", "TypeScript")); console.log(text.trim().includes("World")); console.log(text.trim().startsWith("Hello"));
Most C# string methods have direct TypeScript equivalents with camelCase names: Trim()trim(), ToUpper()toUpperCase(), Contains()includes(), StartsWith()startsWith(). The key difference: JavaScript strings are already UTF-16, and many methods accept regular expressions in addition to plain strings.
Split and join
var csv = "one,two,three"; var parts = csv.Split(','); Console.WriteLine(parts[1]); // two var rejoined = string.Join(" - ", parts); Console.WriteLine(rejoined);
const csv = "one,two,three"; const parts = csv.split(","); console.log(parts[1]); // two const rejoined = parts.join(" - "); console.log(rejoined);
split/join are instance methods on strings and arrays. Unlike C# where string.Join is a static method, TypeScript's join is called on the array: array.join(separator). The separator is optional (defaults to a comma). split accepts a regular expression: text.split(/\s+/).
Multiline strings
var haiku = """ Old pond — a frog jumps in, sound of water. """; Console.WriteLine(haiku.Trim());
const haiku = `Old pond — a frog jumps in, sound of water.`; console.log(haiku);
Template literals (backtick strings) support literal newlines — no need for a raw-string syntax. Indentation is part of the string, so keep content flush left or manage indentation manually. C#'s raw string literals ("""...""") strip leading whitespace based on the closing delimiter's indentation; TypeScript has no equivalent stripping.
Converting to and from strings
int number = 42; string text = number.ToString(); int parsed = int.Parse("123"); bool ok = int.TryParse("abc", out int result); Console.WriteLine($"{text} {parsed} {ok}");
const number = 42; const text = String(number); // or number.toString() const parsed = parseInt("123", 10); // always pass radix 10 const nanResult = parseInt("abc", 10); console.log(text, parsed, Number.isNaN(nanResult));
parseInt and parseFloat parse strings to numbers but return NaN on failure instead of throwing or returning a boolean. Always check with Number.isNaN() (not the global isNaN(), which has surprising coercion). There is no TryParse pattern — use Number.isNaN after parsing.
Numbers & Math
One number type (no int/float/decimal)
int count = 10; double ratio = 0.1 + 0.2; decimal money = 19.99m; Console.WriteLine(count); Console.WriteLine(ratio); Console.WriteLine(money);
const count: number = 10; const ratio: number = 0.1 + 0.2; // 0.30000000000000004 — IEEE 754! const money: number = 19.99; // no decimal type — use a library for money console.log(count); console.log(ratio); console.log(money);
TypeScript's single number type is a 64-bit IEEE 754 float. There is no int, long, double, or decimal. The 0.1 + 0.2 !== 0.3 floating-point issue that C# avoids with decimal is unavoidable for all number arithmetic. Use a dedicated money library (e.g. dinero.js) for financial calculations.
Math functions
Console.WriteLine(Math.Abs(-5)); Console.WriteLine(Math.Sqrt(16.0)); Console.WriteLine(Math.Round(3.75, 1)); Console.WriteLine(Math.Floor(3.9)); Console.WriteLine(Math.Ceiling(3.1)); Console.WriteLine(Math.Max(10, 20));
console.log(Math.abs(-5)); console.log(Math.sqrt(16)); console.log(Math.round(3.75)); // rounds to nearest integer only console.log(Math.floor(3.9)); console.log(Math.ceil(3.1)); console.log(Math.max(10, 20));
The Math object provides the same core functions as C#'s Math class, with camelCase names (Math.sqrt, Math.ceil). One difference: JavaScript's Math.round rounds to the nearest integer only — there is no built-in equivalent of C#'s Math.Round(value, decimals). Use +(value.toFixed(n)) to round to n decimal places.
BigInteger / bigint
using System.Numerics; var big = BigInteger.Parse("99999999999999999999999999"); Console.WriteLine(big * 2);
const big = 99999999999999999999999999n; // bigint literal (n suffix) console.log(big * 2n);
TypeScript has a built-in bigint primitive type for arbitrary-precision integers, written with an n suffix: 9007199254740993n. Unlike C#'s BigInteger (a class requiring a using import), bigint is a primitive. You cannot mix bigint and number in arithmetic — they must be converted explicitly.
Arrays & Tuples
Arrays
var numbers = new int[] { 1, 2, 3, 4, 5 }; Console.WriteLine(numbers[0]); Console.WriteLine(numbers.Length); numbers[0] = 10; Console.WriteLine(numbers[0]);
const numbers: number[] = [1, 2, 3, 4, 5]; // or Array<number> console.log(numbers[0]); console.log(numbers.length); // lowercase — it's a property, not a method numbers[0] = 10; // const binding, but array contents are mutable console.log(numbers[0]);
TypeScript arrays are written as Type[] or Array<Type>. Unlike C#, arrays are dynamically resizable — they are backed by JavaScript objects, not fixed-size memory blocks. length is a property, not a method. A const array binding is still mutable — use ReadonlyArray<T> or readonly T[] to prevent element mutation.
Array methods (vs LINQ)
var numbers = new[] { 1, 2, 3, 4, 5, 6 }; var evens = numbers.Where(n => n % 2 == 0).ToArray(); var doubled = evens.Select(n => n * 2).ToArray(); var sum = doubled.Sum(); Console.WriteLine(string.Join(", ", doubled)); Console.WriteLine(sum);
const numbers = [1, 2, 3, 4, 5, 6]; const evens = numbers.filter(n => n % 2 === 0); const doubled = evens.map(n => n * 2); const sum = doubled.reduce((accumulator, n) => accumulator + n, 0); console.log(doubled.join(", ")); console.log(sum);
JavaScript's built-in array methods mirror C# LINQ: filterWhere, mapSelect, reduceAggregate, findFirstOrDefault, someAny, everyAll. They return new arrays (immutable style); they do not require .ToArray() at the end. Chaining is identical.
Tuples
var point = (X: 3.0, Y: 4.0); Console.WriteLine($"({point.X}, {point.Y})"); (string Name, int Age) person = ("Alice", 30); Console.WriteLine(person.Name);
const point: [number, number] = [3.0, 4.0]; console.log(`(${point[0]}, ${point[1]})`); const person: [string, number] = ["Alice", 30]; console.log(person[0]);
TypeScript tuples are typed fixed-length arrays: [string, number] means exactly two elements, first a string, then a number. Unlike C# value tuples, TypeScript tuple elements are accessed by numeric index ([0], [1]), not by name. Named tuple elements ([x: number, y: number]) exist in TypeScript 4.0+ for documentation purposes but do not add property access.
Readonly / immutable arrays
var mutable = new[] { 1, 2, 3 }; var immutable = Array.AsReadOnly(mutable); // ReadOnlyCollection // immutable[0] = 99; // CS1501 — no indexer setter Console.WriteLine(immutable[0]);
const mutable: number[] = [1, 2, 3]; const immutable: readonly number[] = mutable; // or ReadonlyArray<number> // immutable[0] = 99; // TS2542 — Index signature only permits reading console.log(immutable[0]);
readonly T[] (equivalent: ReadonlyArray<T>) marks an array as immutable at the type level — no push, pop, or index assignment. The underlying array is not frozen (it can still be mutated through the mutable reference); this is a compile-time constraint only. Use Object.freeze(arr) for a runtime-enforced immutable array.
Spread and rest in arrays
var first = new[] { 1, 2, 3 }; var second = new[] { 4, 5, 6 }; var combined = first.Concat(second).ToArray(); Console.WriteLine(string.Join(", ", combined));
const first = [1, 2, 3]; const second = [4, 5, 6]; const combined = [...first, ...second]; // spread operator console.log(combined.join(", "));
The spread operator (...) expands an iterable into an array literal. It is the idiomatic way to combine arrays: [...a, ...b] replaces LINQ's a.Concat(b).ToArray(). Spread also works for shallow-copying arrays: [...original]. In function calls, spread passes array elements as individual arguments: Math.max(...numbers).
Objects & Maps
Object types (shape annotations)
var product = new Product("Widget", 9.99m); Console.WriteLine($"{product.Name}: ${product.Price}"); record Product(string Name, decimal Price);
type Product = { name: string; price: number }; const product: Product = { name: "Widget", price: 9.99 }; console.log(`${product.name}: $${product.price}`);
TypeScript object types describe the shape of a plain JavaScript object. The type keyword creates a named alias for that shape. There is no separate record/value-type distinction — all objects are reference types. Properties are accessed with . just like C# properties. Semicolons or commas can separate members in a type literal.
Optional properties
var user = new User("Alice"); Console.WriteLine(user.Email ?? "(none)"); record User(string Name, string? Email = null);
type User = { name: string; email?: string }; const user: User = { name: "Alice" }; // email omitted — OK console.log(user.email ?? "(none)");
The ? suffix on a property name marks it as optional — the value may be undefined when not provided. This is similar to C#'s nullable properties (string?) but with an important difference: accessing an optional TypeScript property gives undefined, not null. Use ?? (nullish coalescing) to provide a default.
Map (dictionary)
var scores = new Dictionary<string, int> { { "Alice", 95 }, { "Bob", 82 }, }; scores["Charlie"] = 88; Console.WriteLine(scores["Alice"]); Console.WriteLine(scores.ContainsKey("Dave"));
const scores = new Map<string, number>([ ["Alice", 95], ["Bob", 82], ]); scores.set("Charlie", 88); console.log(scores.get("Alice")); console.log(scores.has("Dave"));
TypeScript's Map<K, V> is the equivalent of C#'s Dictionary<TKey, TValue>. Unlike plain JavaScript objects, Map preserves insertion order, accepts any key type (not just strings), and has get/set/has/delete methods. Use Map when the key is not a known string literal; use a typed object literal when keys are known ahead of time.
Record&lt;K, V&gt; — typed object maps
var lookup = new Dictionary<string, int> { { "one", 1 }, { "two", 2 }, { "three", 3 }, }; foreach (var (key, value) in lookup) Console.WriteLine($"{key}: {value}");
const lookup: Record<string, number> = { one: 1, two: 2, three: 3, }; for (const [key, value] of Object.entries(lookup)) { console.log(`${key}: ${value}`); }
Record<K, V> is a built-in utility type describing an object whose keys are of type K and values of type V. It is the idiomatic type for a plain object used as a string-keyed map. Object.entries() returns [key, value] pairs (equivalent to C#'s foreach over a dictionary). Unlike Map, Record objects have no .get() method — access via obj[key].
Object and array destructuring
var point = new Point(3.0, 4.0); var (x, y) = point; // positional deconstruct Console.WriteLine($"x={x}, y={y}"); record Point(double X, double Y);
const point = { x: 3.0, y: 4.0 }; const { x, y } = point; // object destructuring console.log(`x=${x}, y=${y}`); const numbers = [10, 20, 30]; const [first, second] = numbers; // array destructuring console.log(first, second);
TypeScript destructuring pulls values out of objects and arrays into named variables. Object destructuring uses { prop } (names must match the property); rename with { prop: alias }. Array destructuring uses positional [first, second]. Default values work in both: { name = "default" }. This is more flexible than C#'s positional deconstruction.
Control Flow
if / else
var temperature = 72; if (temperature > 80) Console.WriteLine("Hot"); else if (temperature > 60) Console.WriteLine("Comfortable"); else Console.WriteLine("Cold");
const temperature = 72; if (temperature > 80) { console.log("Hot"); } else if (temperature > 60) { console.log("Comfortable"); } else { console.log("Cold"); }
The syntax is nearly identical. TypeScript (and JavaScript) convention strongly favors braces even for single-statement branches — most linters enforce this. One key difference: TypeScript's if condition is implicitly coerced to a boolean, so empty string, 0, null, and undefined are all falsy. Use explicit comparisons (=== null) to be safe.
switch and type narrowing
object value = "hello"; switch (value) { case string s: Console.WriteLine($"String: {s.ToUpper()}"); break; case int number: Console.WriteLine($"Number: {number * 2}"); break; }
const value: string | number = "hello"; switch (typeof value) { case "string": console.log(`String: ${value.toUpperCase()}`); // narrowed to string break; case "number": console.log(`Number: ${value * 2}`); // narrowed to number break; }
TypeScript narrows a union type inside each case branch — inside case "string", the compiler knows value is a string and permits string methods. This is TypeScript's equivalent of C# switch pattern matching on types. For class instances, use instanceof instead of typeof.
Null-coalescing and optional chaining
string? name = null; string display = name ?? "Guest"; Console.WriteLine(display); string? upper = name?.ToUpper(); Console.WriteLine(upper ?? "(null)");
const name: string | null | undefined = null; const display = name ?? "Guest"; // ?? handles null AND undefined console.log(display); const upper = name?.toUpperCase(); // optional chaining — undefined if null console.log(upper ?? "(null)");
?? (nullish coalescing) and ?. (optional chaining) work the same as C#. The key difference: TypeScript's ?? guards against both null and undefined, while C#'s ?? only guards against null. The || operator is not equivalent — it also triggers on any falsy value (0, "").
null vs undefined — two nothingness values
// C# has one null — a missing reference string? value = null; Console.WriteLine(value is null); // true Console.WriteLine(value == null); // true (same thing)
// TypeScript has TWO: null (explicit absence) and undefined (not set) const explicitNull: string | null = null; let notInitialized: string | undefined; // undefined by default console.log(explicitNull === null); // true console.log(notInitialized === undefined); // true console.log(explicitNull == notInitialized); // true (loose == treats both as nullish) console.log(explicitNull === notInitialized); // false (strict === distinguishes them)
TypeScript inherits JavaScript's two-nothingness design. undefined means "never assigned"; null means "explicitly absent". Use ?? to handle both at once. Most TypeScript code treats them interchangeably, but strictNullChecks (enabled in strict mode) makes the type system track them separately. Always use === for comparison — == equates null and undefined.
Ternary operator
var score = 85; var grade = score >= 90 ? "A" : score >= 80 ? "B" : "C"; Console.WriteLine(grade);
const score = 85; const grade = score >= 90 ? "A" : score >= 80 ? "B" : "C"; console.log(grade);
The ternary operator is identical in both languages. In TypeScript it is also an expression (not just a statement like if) and can be used inside template literals: `Score: ${score >= 60 ? "pass" : "fail"}`.
Functions
Function declaration
static string Greet(string name) => $"Hello, {name}!"; Console.WriteLine(Greet("World"));
function greet(name: string): string { return `Hello, ${name}!`; } console.log(greet("World"));
TypeScript function declarations use the function keyword with parameter types after the name and return type after the parameter list. The return type annotation is optional when it can be inferred. Functions are hoisted (can be called before they are defined in the file). There is no static keyword at module level — all module-level functions are effectively static.
Arrow functions (lambdas)
Func<int, int, int> add = (a, b) => a + b; Console.WriteLine(add(3, 4)); var numbers = new[] { 1, 2, 3, 4, 5 }; var squared = numbers.Select(n => n * n).ToArray(); Console.WriteLine(string.Join(", ", squared));
const add = (a: number, b: number): number => a + b; console.log(add(3, 4)); const numbers = [1, 2, 3, 4, 5]; const squared = numbers.map(n => n * n); console.log(squared.join(", "));
Arrow functions ((params) => expression) are TypeScript's equivalent of C# lambdas. They capture this from the enclosing scope (unlike regular functions, which define their own this). For multi-statement bodies, add braces and an explicit return: (n) => { const doubled = n * 2; return doubled; }. Arrow functions are the preferred form in most modern TypeScript.
Default and rest parameters
static string Greet(string name, string greeting = "Hello") => $"{greeting}, {name}!"; Console.WriteLine(Greet("Alice")); Console.WriteLine(Greet("Alice", "Hi")); static int Sum(params int[] numbers) => numbers.Sum(); Console.WriteLine(Sum(1, 2, 3, 4));
function greet(name: string, greeting = "Hello"): string { return `${greeting}, ${name}!`; } console.log(greet("Alice")); console.log(greet("Alice", "Hi")); function sum(...numbers: number[]): number { return numbers.reduce((accumulator, n) => accumulator + n, 0); } console.log(sum(1, 2, 3, 4));
Default parameter values and rest parameters (...args) work the same as C#. Unlike C#, TypeScript does not support optional parameters with a ? suffix unless the type also includes undefined: name?: string and name: string | undefined are almost equivalent. Named arguments (C#'s Greet(greeting: "Hi", name: "Alice")) do not exist — arguments must always be positional.
Higher-order functions
static Func<int, int> Multiplier(int factor) => number => number * factor; var triple = Multiplier(3); Console.WriteLine(triple(5)); // 15
function multiplier(factor: number): (n: number) => number { return n => n * factor; } const triple = multiplier(3); console.log(triple(5)); // 15
Functions that return functions work the same way. The return type annotation for a function type is written as (param: Type) => ReturnType. TypeScript infers the closure's captured variables automatically. This pattern (returning a configured function) is idiomatic in TypeScript for currying, memoization, and middleware.
Function overloads (declaration-only)
Console.WriteLine(Describer.Describe(42)); Console.WriteLine(Describer.Describe("hello")); static class Describer { public static string Describe(int value) => $"int: {value}"; public static string Describe(string value) => $"string: {value}"; }
// TypeScript: declare overloads, then write ONE implementation function describe(value: number): string; function describe(value: string): string; function describe(value: number | string): string { if (typeof value === "number") return `number: ${value}`; return `string: ${value}`; } console.log(describe(42)); console.log(describe("hello"));
TypeScript supports overload declarations (multiple type signatures) but requires a single implementation that handles all cases. Unlike C#, there is no runtime dispatch — the implementation must branch on typeof or instanceof itself. The overload declarations are purely for the type checker and callers; the implementation signature is hidden from callers.
Generics
Generic functions
static T First<T>(T[] items) => items[0]; Console.WriteLine(First(new[] { 10, 20, 30 })); Console.WriteLine(First(new[] { "a", "b" }));
function first<T>(items: T[]): T { return items[0]; } console.log(first([10, 20, 30])); console.log(first(["a", "b"]));
Generic functions use the same angle-bracket syntax as C#: <T> after the function name. TypeScript usually infers the type argument from the call site, so explicit type arguments (first<number>(...)) are rarely needed. The type parameter may appear in parameter types, return types, and local variable types within the function body.
Generic constraints
static T Max<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) > 0 ? a : b; Console.WriteLine(Max(3, 7)); Console.WriteLine(Max("apple", "banana"));
function max<T extends { valueOf(): number }>(a: T, b: T): T { return a.valueOf() > b.valueOf() ? a : b; } // Simpler with a direct constraint: function maxNum<T extends number | string>(a: T, b: T): T { return a > b ? a : b; } console.log(maxNum(3, 7)); console.log(maxNum("apple", "banana"));
TypeScript uses extends instead of where for constraints. The constraint can be any type — a class, an interface shape, a union, or a primitive. Because TypeScript uses structural typing, the constraint just needs to describe the shape required: <T extends { length: number }> means "any T that has a numeric length property."
Utility types (Partial, Readonly, Pick, Record)
// C# uses separate types / LINQ for these operations record User(string Name, string Email, int Age); // No direct language-level equivalent of Partial<User>
type User = { name: string; email: string; age: number }; type PartialUser = Partial<User>; // all fields optional type ReadonlyUser = Readonly<User>; // all fields readonly type NameEmail = Pick<User, "name" | "email">; // subset of fields type OmitAge = Omit<User, "age">; // exclude a field const partial: PartialUser = { name: "Alice" }; // email, age omitted console.log(partial.name);
Built-in utility types transform existing types at the type level with no runtime cost. Partial<T> makes all properties optional (useful for update payloads). Readonly<T> makes all properties readonly. Pick/Omit extract or exclude named properties. These are TypeScript's equivalent of what C# achieves with separate DTO classes or with expressions.
keyof — constraining to property names
// C# reflection-based approach: static object? Get(object obj, string propName) => obj.GetType().GetProperty(propName)?.GetValue(obj);
type User = { name: string; age: number }; function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const user: User = { name: "Alice", age: 30 }; console.log(getProperty(user, "name")); // string — type-safe console.log(getProperty(user, "age")); // number — type-safe // getProperty(user, "email"); // TS error — not a key of User
keyof T produces a union of all property name strings of type T — e.g. keyof User is "name" | "age". Combined with indexed access types (T[K]), this enables type-safe property lookup at compile time — something C# only achieves at runtime via reflection. This pattern is ubiquitous in TypeScript utility functions and ORMs.
The Type System
Union types
// C# discriminated unions (limited): object value = "hello"; if (value is string s) Console.WriteLine($"String: {s.ToUpper()}"); else if (value is int n) Console.WriteLine($"Number: {n * 2}");
type StringOrNumber = string | number; function process(value: StringOrNumber): string { if (typeof value === "string") { return `String: ${value.toUpperCase()}`; } return `Number: ${value * 2}`; } console.log(process("hello")); console.log(process(42));
TypeScript union types (A | B) are a first-class feature: a variable of type string | number can hold either. The compiler tracks which type is active through control flow (type narrowing), providing accurate type information inside each branch. C# 9+ has union-like patterns via object and switch expressions, but lacks the compile-time union type.
Literal types and string enum equivalents
Move(Direction.North); static void Move(Direction direction) => Console.WriteLine($"Moving {direction}"); enum Direction { North, South, East, West }
type Direction = "north" | "south" | "east" | "west"; function move(direction: Direction): void { console.log(`Moving ${direction}`); } move("north"); // move("up"); // TS error — not a valid Direction
Literal types ("north" | "south") constrain a value to specific string (or number) values. This is the idiomatic TypeScript equivalent of a C# enum for string-based choices. Unlike C# enums, the values are plain strings at runtime — no conversion needed. TypeScript also has a numeric enum and const enum construct that resembles C# enums more closely.
Discriminated unions
Console.WriteLine(Area(new Circle(5.0))); static double Area(Shape shape) => shape switch { Circle circle => Math.PI * circle.Radius * circle.Radius, Rectangle rect => rect.Width * rect.Height, _ => throw new ArgumentException("Unknown shape"), }; abstract record Shape; record Circle(double Radius) : Shape; record Rectangle(double Width, double Height) : Shape;
type Circle = { kind: "circle"; radius: number }; type Rectangle = { kind: "rectangle"; width: number; height: number }; type Shape = Circle | Rectangle; function area(shape: Shape): number { switch (shape.kind) { case "circle": return Math.PI * shape.radius ** 2; case "rectangle": return shape.width * shape.height; } } console.log(area({ kind: "circle", radius: 5.0 }));
A discriminated union is a union type where each member has a common literal-type field (the discriminant). TypeScript narrows the type inside each case based on the discriminant. If the switch is not exhaustive, the compiler reports an error. This pattern replaces C#'s abstract class hierarchy + pattern matching with plain object types and no inheritance.
Intersection types
// C# achieves combined types via interface inheritance, not intersection syntax. // A class can implement multiple interfaces to satisfy combined contracts: // interface ILoggable { void Log(); } // interface INamed { string Name { get; } } // class User : INamed, ILoggable { ... }
type Named = { name: string }; type Emailed = { email: string }; type User = Named & Emailed; // intersection: must have BOTH const user: User = { name: "Alice", email: "alice@example.com" }; console.log(user.name, user.email);
Intersection types (A & B) combine types so the value must satisfy both. This is similar to implementing multiple interfaces in C# but without a class hierarchy — it is purely structural. Intersection types are often used to mixin additional properties: type WithTimestamp = T & { createdAt: Date }.
Type narrowing
object value = GetValue(); if (value is string text) Console.WriteLine(text.ToUpper()); else if (value is int number) Console.WriteLine(number * 2); static object GetValue() => "hello";
function getValue(): string | number { return "hello"; } const value = getValue(); if (typeof value === "string") { console.log(value.toUpperCase()); // value: string here } else { console.log(value * 2); // value: number here }
TypeScript performs control flow analysis: after a typeof or instanceof check, the compiler narrows the type inside that branch. Other narrowing forms include in checks ("prop" in obj), truthiness checks (if (value) eliminates null/undefined), and custom type predicates (function isString(x): x is string).
Classes
Class basics
var animal = new Animal("Rex"); Console.WriteLine(animal.Speak()); class Animal { public string Name { get; } public Animal(string name) { Name = name; } public virtual string Speak() => $"{Name} makes a sound"; }
class Animal { readonly name: string; constructor(name: string) { this.name = name; } speak(): string { return `${this.name} makes a sound`; } } const animal = new Animal("Rex"); console.log(animal.speak());
TypeScript classes look very similar to C# classes. Key differences: properties must be declared in the class body before they can be assigned in the constructor; this. is required inside methods to access instance members; methods are virtual by default (no keyword needed — all methods can be overridden). The shorthand parameter syntax constructor(public name: string) declares and assigns in one step.
Access modifiers
var account = new BankAccount(); account.Deposit(100m); Console.WriteLine(account.Balance); class BankAccount { private decimal balance = 0; public void Deposit(decimal amount) { balance += amount; } public decimal Balance => balance; }
class BankAccount { #balance = 0; // true private field (JS native) deposit(amount: number): void { this.#balance += amount; } get balance(): number { return this.#balance; } } const account = new BankAccount(); account.deposit(100); console.log(account.balance);
TypeScript supports private, protected, and public keywords (compile-time only — erased at runtime), plus JavaScript's native #field syntax for truly private fields (enforced at runtime). Prefer #field for fields that must be private at runtime. Getters and setters use the get/set keyword and are called without parentheses, just like C# properties.
Abstract classes
new Circle(5).Describe(); abstract class Shape { public abstract double Area(); public void Describe() => Console.WriteLine($"Area: {Area():F2}"); } class Circle : Shape { double radius; public Circle(double radius) { this.radius = radius; } public override double Area() => Math.PI * radius * radius; }
abstract class Shape { abstract area(): number; describe(): void { console.log(`Area: ${this.area().toFixed(2)}`); } } class Circle extends Shape { constructor(private radius: number) { super(); } area(): number { return Math.PI * this.radius ** 2; } } new Circle(5).describe();
Abstract classes work the same way: abstract on the class prevents direct instantiation; abstract on a method requires subclasses to implement it. TypeScript uses extends (not :) for inheritance. The override keyword exists in TypeScript (4.3+) but is optional by default; enable noImplicitOverride to require it.
Constructor parameter properties (shorthand)
var point = new Point(3.0, 4.0); Console.WriteLine($"({point.X}, {point.Y})"); record Point(double X, double Y);
class Point { constructor( public readonly x: number, public readonly y: number, ) {} } const point = new Point(3.0, 4.0); console.log(`(${point.x}, ${point.y})`);
Prefixing a constructor parameter with public, private, protected, or readonly automatically declares and assigns it as a class property — no separate field declaration and this.x = x needed. This is TypeScript's equivalent of C# positional records for simple data classes. Adding readonly makes it immutable after construction.
Interfaces & Structural Typing
Interfaces
IGreeter greeter = new FriendlyGreeter(); Console.WriteLine(greeter.Greet("Alice")); interface IGreeter { string Greet(string name); } class FriendlyGreeter : IGreeter { public string Greet(string name) => $"Hello, {name}!"; }
interface Greeter { greet(name: string): string; } class FriendlyGreeter implements Greeter { greet(name: string): string { return `Hello, ${name}!`; } } const greeter: Greeter = new FriendlyGreeter(); console.log(greeter.greet("Alice"));
TypeScript interfaces look similar to C# interfaces. The implements keyword is optional — TypeScript checks compatibility structurally, so class FriendlyGreeter would satisfy Greeter even without the explicit implements clause. Declaring implements adds a compile-time assertion that the class satisfies the interface.
Structural typing (duck typing with types)
// C# is nominally typed — the class must declare it implements the interface interface IHasLength { int Length { get; } } // string already has Length, but it does NOT implement IHasLength // — you'd need an explicit adapter class
interface HasLength { length: number } function describe(value: HasLength): string { return `Length: ${value.length}`; } // Any object with a numeric 'length' property satisfies HasLength: console.log(describe("hello")); // string has .length console.log(describe([1, 2, 3])); // array has .length console.log(describe({ length: 7 })); // plain object — also fine
This is TypeScript's biggest paradigm shift from C#. TypeScript uses structural typing: if a value has the required properties, it satisfies the interface — no implements declaration needed. A string satisfies HasLength automatically because it has a length property. C# uses nominal typing: a type must explicitly declare that it implements an interface.
Extending interfaces
IDog dog = new Dog("Rex", "Labrador"); Console.WriteLine($"{dog.Name} ({dog.Breed})"); interface IAnimal { string Name { get; } } interface IDog : IAnimal { string Breed { get; } } record Dog(string Name, string Breed) : IDog;
interface Animal { name: string } interface Dog extends Animal { breed: string } const dog: Dog = { name: "Rex", breed: "Labrador" }; console.log(`${dog.name} (${dog.breed})`);
Interfaces extend other interfaces with extends, just like C# interface inheritance. An interface can extend multiple interfaces: interface C extends A, B. The extending interface inherits all members of the base interface and may add new ones. A type alias achieves the same with intersection: type Dog = Animal & { breed: string }.
interface vs type alias
// C# has no meaningful equivalent of this distinction interface IUser { string Name { get; } } // Only interfaces can be inherited — C# doesn't "merge" types
// Both describe the same shape: interface UserInterface { name: string } type UserType = { name: string }; // interface: can be extended and merged (declaration merging) interface UserInterface { age: number } // merged — now has name + age // type: can express unions, tuples, and complex types interfaces cannot type StringOrNumber = string | number; type Pair = [string, number]; const user: UserInterface = { name: "Alice", age: 30 }; console.log(user.name, user.age);
Use interface for object shapes that may be extended or implemented by classes, and when declaration merging (adding properties in multiple declarations) is intentional. Use type for unions, intersections, tuples, mapped types, and complex computed types that interface cannot express. In practice, either works for most plain object shapes.
Async & Promises
async / await
async Task<string> FetchGreeting(string name) { await Task.Delay(0); // simulate async work return $"Hello, {name}!"; } Console.WriteLine(await FetchGreeting("World"));
async function fetchGreeting(name: string): Promise<string> { await Promise.resolve(); // simulate async work return `Hello, ${name}!`; } async function main(): Promise<void> { console.log(await fetchGreeting("World")); } main();
async/await works the same way: mark a function async, and it returns a Promise<T> (TypeScript's equivalent of Task<T>). Use await inside async functions to unwrap the promise. Top-level await is supported in ES modules and in tsx scripts. There is no Task.Run equivalent — TypeScript is single-threaded.
Running async tasks in parallel (Promise.all)
async Task<int> Fetch(int value) { await Task.Delay(0); return value * 10; } var results = await Task.WhenAll(Fetch(1), Fetch(2), Fetch(3)); Console.WriteLine(string.Join(", ", results));
async function fetch(value: number): Promise<number> { await Promise.resolve(); return value * 10; } async function main(): Promise<void> { const results = await Promise.all([fetch(1), fetch(2), fetch(3)]); console.log(results.join(", ")); } main();
Promise.all() is the equivalent of Task.WhenAll() — it runs all promises concurrently and resolves when all complete. The result is a typed array: Promise<number[]> from Promise.all([p1, p2, p3]) where each is Promise<number>. Use Promise.allSettled() to get results even when some reject (similar to Task.WhenAll with per-task error handling).
Async error handling
async Task<string> RiskyFetch() { await Task.Delay(0); throw new InvalidOperationException("Network error"); } try { await RiskyFetch(); } catch (InvalidOperationException ex) { Console.WriteLine($"Caught: {ex.Message}"); }
async function riskyFetch(): Promise<string> { await Promise.resolve(); throw new Error("Network error"); } async function main(): Promise<void> { try { await riskyFetch(); } catch (error) { if (error instanceof Error) { console.log(`Caught: ${error.message}`); } } } main();
Async errors propagate through try/catch just like synchronous ones. The catch variable is typed unknown in strict TypeScript — you must narrow to Error with instanceof before accessing .message. Unlike C# where you catch specific exception types (catch (IOException ex)), TypeScript's catch block always catches everything; use instanceof to branch on error type.
⚠ Gotchas for C# Developers
=== vs == — always use ===
// C# == is safe — always structural equality for value types Console.WriteLine(1 == "1"); // false — type mismatch, compile error Console.WriteLine(1 == 1); // true
// JavaScript == performs type coercion — surprising results: console.log(1 == "1"); // true — string coerced to number! console.log(0 == false); // true — false coerced to 0 console.log(null == undefined); // true — intentional JS design // Use === (strict equality) — no coercion: console.log(1 === "1"); // false — different types console.log(1 === 1); // true
JavaScript's == operator applies type coercion before comparing, producing famously surprising results. Always use === (strict equality) in TypeScript. The only common legitimate use of == is the null == undefined check as a shorthand for "is null or undefined" — but even that is clearer written as value == null explicitly. Most linters ban == by default.
Types are erased at runtime
// C#: type info exists at runtime via reflection object value = "hello"; Console.WriteLine(value.GetType().Name); // String Console.WriteLine(value is string); // true
// TypeScript: types are compile-time only — erased to plain JS const value: string = "hello"; console.log(typeof value); // "string" — JS runtime type // No .GetType() — TypeScript types do not exist at runtime // instanceof works for classes, not type aliases or interfaces: console.log(value instanceof String); // false — primitive, not object console.log(typeof value === "string"); // true — the correct check
TypeScript types are completely erased when compiled to JavaScript — there is no runtime equivalent of C#'s reflection or is pattern matching on TypeScript types. You cannot write value is MyInterface at runtime because the interface no longer exists. Runtime type checking uses JavaScript's typeof (for primitives) and instanceof (for class instances). Discriminant properties on union types replace runtime type tests.
as is a compile-time assertion, not a safe cast
// C#: as returns null if the cast fails (safe) object value = 42; var text = value as string; // null — no exception Console.WriteLine(text is null); // true
// TypeScript: as overrides the type — no runtime check! const value: unknown = 42; const text = value as string; // compiles, but lies — it's still 42 console.log(typeof text); // "number" — TypeScript trusted your lie // text.toUpperCase() would throw at runtime — number has no toUpperCase
TypeScript's as keyword asserts "I know the type is X" and suppresses type errors — it does NOT perform any runtime cast or check. If your assertion is wrong, you get a runtime error. C#'s as operator safely returns null on failure. The TypeScript equivalent of a safe cast is to narrow with typeof/instanceof first, then access the value.
No true method overloading at runtime
// C# supports real overloading — separate implementations per type: Printer.Print(42); Printer.Print("hello"); static class Printer { public static void Print(int value) => Console.WriteLine($"int: {value}"); public static void Print(string value) => Console.WriteLine($"string: {value}"); }
// TypeScript: overload declarations, ONE implementation function print(value: number): void; function print(value: string): void; function print(value: number | string): void { if (typeof value === "number") { console.log(`number: ${value}`); } else { console.log(`string: ${value}`); } } print(42); print("hello");
TypeScript's overload signatures are compile-time declarations only — there is exactly one runtime implementation, and it receives the broadest union type. The implementation must check the actual type itself. Unlike C# where the compiler dispatches to the correct overload, TypeScript's dispatch is manual. In practice, union types and optional parameters often eliminate the need for overloads entirely.