PONY λ M2 Modula-2

C#.CodeCompared.To/JavaScript

An interactive executable cheatsheet comparing C# and JavaScript

C# 13 JavaScript (ES2025)
Hello World & Output
Hello, World
Console.WriteLine("Hello, World!");
console.log("Hello, World!");
JavaScript's console.log is the everyday equivalent of Console.WriteLine: it accepts any number of arguments separated by spaces and always appends a newline. In the browser, output appears in the DevTools console; in Node.js it goes to stdout. There is no compilation step — a .js file runs directly.
String interpolation / template literals
var name = "JavaScript"; var version = 2025; Console.WriteLine($"Hello from {name} (ES{version})!");
const name = "JavaScript"; const version = 2025; console.log(`Hello from ${name} (ES${version})!`);
JavaScript uses backtick template literals (`...${expr}...`) instead of C#'s $"...{expr}...". The syntax is nearly a direct swap: backticks instead of quotes, ${} instead of {}. Template literals also allow literal newlines without an escape sequence.
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 one, use process.stdout.write() in Node.js — there is no direct equivalent of Console.Write that works identically in the browser and on the server. console.error writes to stderr, just like Console.Error.WriteLine.
Debugging output
var user = new { Name = "Alice", Roles = new[] { "admin" } }; Console.WriteLine(user); Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(user));
const user = { name: "Alice", roles: ["admin"] }; console.log(user); // structured, expandable in a real console console.dir(user, { depth: null }); console.log(JSON.stringify(user, null, 2));
Where C# needs a serializer call or a debugger to inspect an object's shape, console.log already prints objects and arrays readably — no ToString() override required. console.table renders an array of objects as a grid, and console.dir with { depth: null } expands deeply nested data that console.log would otherwise truncate.
Variables & Types
var means something different in each language
var greeting = "Hello"; // C# var: type-inferred, STILL statically typed string name = "Alice"; // greeting = 42; // CS0029 — compile error, greeting is a string Console.WriteLine($"{greeting}, {name}!");
var greeting = "Hello"; // JS var: function-scoped, NO type at all let name = "Alice"; // prefer let — block-scoped, reassignable const PI = 3.14159; // const — block-scoped, cannot be reassigned greeting = 42; // legal! JavaScript has no static types console.log(`${greeting}, ${name}!`);
This is a classic false-friend trap: C#'s var is still statically typed — the compiler infers a concrete type once and enforces it forever. JavaScript's var has nothing to do with type inference; it means "function-scoped, reassignable to any type." Modern JavaScript avoids var entirely in favor of let (reassignable, block-scoped) and const (single-assignment, block-scoped) — treat var as legacy syntax you will encounter but should not write.
Dynamic typing — no compile-time checks
int count = 42; // count = "oops"; // CS0029 — caught at COMPILE time, before the program ever runs Console.WriteLine(count);
let count = 42; count = "oops"; // perfectly legal — count is now a string console.log(count); // "oops" count = count.length; // and now it's a number again — nothing stops this
C# catches a type mismatch while you are still writing the code. JavaScript has no compile step and no static type checker at all — a variable can hold any type at any time, and the runtime happily lets it change type on every assignment. Bugs that C# would have caught before the program ran surface here only when the wrong-typed value actually gets used, often far from where it went wrong.
typeof and the type set
Console.WriteLine("hello".GetType().Name); // String Console.WriteLine((42).GetType().Name); // Int32 Console.WriteLine((3.14).GetType().Name); // Double Console.WriteLine(true.GetType().Name); // Boolean
console.log(typeof "hello"); // "string" console.log(typeof 42); // "number" ← no separate int/double console.log(typeof 3.14); // "number" ← same type as 42 console.log(typeof true); // "boolean" console.log(typeof undefined);// "undefined" console.log(typeof {}); // "object" console.log(typeof []); // "object" ← arrays report as "object"!
JavaScript's typeof operator is much coarser than C#'s reflection-based GetType(). There is a single number type covering everything C# splits into int, long, float, double, and decimal. And typeof reports an array as "object" — use Array.isArray() to distinguish an array from a plain object.
null vs undefined — two empty values
// C# has ONE "nothing" value: null. string? value = null; Console.WriteLine(value is null); // true string? notSet; // Console.WriteLine(notSet); // CS0165 — must be assigned before use
// JavaScript has TWO nothing values. let explicitNull = null; // deliberately empty let notInitialized; // undefined — declared but never assigned 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
C#'s nullable reference types give you exactly one absence value, null, and the compiler tracks whether a reference might hold it. JavaScript has no such safety net and splits absence into two distinct values: undefined means "never assigned" (an unset variable, a missing object property, a function with no return statement), while null means "explicitly set to nothing." The nullish coalescing operator ?? treats both as absent at once.
No nullable reference types safety net
#nullable enable string? maybeName = GetName(); // The compiler FORCES a null check before you can call a member: Console.WriteLine(maybeName?.ToUpper() ?? "(none)"); static string? GetName() => null;
function getName() { return null; } const maybeName = getName(); // Nothing stops you from forgetting the check: console.log(maybeName.toUpperCase()); // TypeError: Cannot read properties of null // The safe version, opted into manually: // console.log(maybeName?.toUpperCase() ?? "(none)");
C#'s nullable reference types (#nullable enable) make the compiler flag every place a possibly-null value is dereferenced without a check. JavaScript has no equivalent enforcement mechanism — ?. and ?? exist and work well, but nothing requires you to use them. A missed null check is a runtime TypeError, not a compile error.
Strings
Common string methods
var text = " Hello, World! "; Console.WriteLine(text.Trim()); Console.WriteLine(text.Trim().ToUpper()); Console.WriteLine(text.Trim().Replace("World", "JavaScript")); 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().replaceAll("World", "JavaScript")); console.log(text.trim().includes("World")); console.log(text.trim().startsWith("Hello"));
Most C# string methods have direct JavaScript equivalents with camelCase names: Trim()trim(), ToUpper()toUpperCase(), Contains()includes(), StartsWith()startsWith(). replace swaps only the first match; replaceAll (ES2021+) swaps every one — C#'s Replace always replaces all occurrences, so replaceAll is the closer match.
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 rather than C#'s static string.Join: you call join on the array, not on the string type. split also accepts a regular expression directly: text.split(/\s+/) splits on any run of whitespace.
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 span multiple lines as-is — no special raw-string syntax needed. Every line break and space between the backticks is preserved literally, so indentation is entirely up to how the text is placed; unlike C#'s raw string literals, JavaScript does not strip leading whitespace based on a closing-delimiter indentation rule.
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 failedParse = parseInt("abc", 10); console.log(text, parsed, Number.isNaN(failedParse));
parseInt and parseFloat parse strings to numbers but return NaN on failure instead of throwing or returning a boolean success flag. There is no TryParse pattern — always check the result with Number.isNaN() (not the global isNaN(), which has surprising coercion behavior of its own).
Numbers & Math
One number type (no int/float/decimal distinction)
int count = 10; double ratio = 0.1 + 0.2; decimal money = 19.99m; Console.WriteLine(count); Console.WriteLine(ratio); Console.WriteLine(money);
const count = 10; const ratio = 0.1 + 0.2; // 0.30000000000000004 — IEEE 754! const money = 19.99; // no decimal type — use a library for currency math console.log(count); console.log(ratio); console.log(money);
JavaScript has a single number type — a 64-bit IEEE 754 float — with no int, long, double, or decimal distinction. The 0.1 + 0.2 !== 0.3 rounding surprise that C#'s decimal type exists to avoid is unavoidable for ordinary JavaScript arithmetic; use a dedicated library for money calculations rather than plain numbers.
Math functions
Console.WriteLine(Math.Abs(-5)); Console.WriteLine(Math.Sqrt(16.0)); 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.floor(3.9)); console.log(Math.ceil(3.1)); console.log(Math.max(10, 20));
The global Math object provides the same core functions as C#'s Math class, with camelCase names: Math.sqrt, Math.ceil, Math.floor. Unlike C#'s Math methods, JavaScript's always operate on the single number type — there is no overload set for int versus double arguments.
No integer division operator
Console.WriteLine(7 / 2); // 3 — integer division when both operands are int Console.WriteLine(7.0 / 2); // 3.5 — floating-point division
console.log(7 / 2); // 3.5 — always floating-point, there is no int type console.log(Math.trunc(7 / 2)); // 3 — the way to get integer division console.log(Math.floor(7 / 2)); // 3 — same result here, differs for negative numbers
C# picks integer or floating-point division based on the operand types. JavaScript has no integer type at all, so / always produces a floating-point result. Use Math.trunc() to discard the fractional part (matching C#'s truncation-toward-zero behavior) or Math.floor() if flooring toward negative infinity is what you want instead.
BigInteger / bigint
using System.Numerics; var big = BigInteger.Parse("99999999999999999999999999"); Console.WriteLine(big * 2);
const big = 99999999999999999999999999n; // bigint literal — the n suffix console.log(big * 2n);
JavaScript has a built-in bigint primitive for arbitrary-precision integers, written with an n suffix — 9007199254740993n. Unlike C#'s BigInteger (a class requiring a using import), bigint is a language primitive. It cannot be mixed with number in arithmetic — both operands must be converted to the same type first.
Arrays & Objects
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 = [1, 2, 3, 4, 5]; console.log(numbers[0]); console.log(numbers.length); // property, not a method numbers[0] = 10; // const binding, but array contents are still mutable console.log(numbers[0]);
JavaScript arrays are dynamically resizable — they are not fixed-size memory blocks the way C# arrays are, and grow or shrink with push/pop/splice. length is a property, not a method call. A const array binding still allows mutating the array's contents; only reassigning the variable itself is forbidden.
Array methods vs LINQ — a direct parallel
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);
This is the strongest bridge point between the two languages: JavaScript's built-in array methods mirror LINQ almost one to one — filter is Where, map is Select, reduce is Aggregate, find is FirstOrDefault, some is Any, every is All. They chain the same way and return new arrays without needing a closing .ToArray() — a C# programmer's LINQ intuition transfers almost directly.
Objects (vs C# anonymous types / dictionaries)
var product = new { Name = "Widget", Price = 9.99m }; Console.WriteLine($"{product.Name}: ${product.Price}");
const product = { name: "Widget", price: 9.99 }; console.log(`${product.name}: $${product.price}`); product.price = 12.99; // plain objects are mutable by default console.log(product.price);
A JavaScript object literal is closest to a C# anonymous type, but far more flexible: properties are freely mutable by default (an anonymous type's properties are read-only), and there is no compiler-checked shape — a typo in a property name is simply a new property, not an error. Objects are the workhorse data structure of JavaScript, used where C# would reach for a class, a record, a dictionary, or an anonymous type depending on context.
Map (vs 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([ ["Alice", 95], ["Bob", 82], ]); scores.set("Charlie", 88); console.log(scores.get("Alice")); console.log(scores.has("Dave"));
JavaScript's Map is the closest equivalent of C#'s Dictionary<TKey, TValue> — it preserves insertion order, accepts any key type (not just strings, unlike a plain object), and has get/set/has/delete methods. Prefer Map over a plain object whenever the keys are not known ahead of time or are not strings.
Iterating object properties
var lookup = new Dictionary<string, int> { { "one", 1 }, { "two", 2 }, { "three", 3 }, }; foreach (var (key, value) in lookup) Console.WriteLine($"{key}: {value}");
const lookup = { one: 1, two: 2, three: 3 }; for (const [key, value] of Object.entries(lookup)) { console.log(`${key}: ${value}`); } console.log(Object.keys(lookup)); // ["one", "two", "three"] console.log(Object.values(lookup)); // [1, 2, 3]
Object.entries() returns an array of [key, value] pairs — the equivalent of iterating a C# Dictionary with foreach. Object.keys() and Object.values() give just one half of the pair. Unlike a Dictionary, a plain object was not designed as a general-purpose map, so this pattern works best when the keys are known, string-like identifiers.
Destructuring & Spread
Array destructuring (vs tuple deconstruction)
var point = (X: 3.0, Y: 4.0); var (x, y) = point; Console.WriteLine($"x={x}, y={y}");
const point = [3.0, 4.0]; const [x, y] = point; // positional, like C# tuple deconstruction console.log(`x=${x}, y=${y}`); const numbers = [10, 20, 30]; const [first, , third] = numbers; // skip an element with an empty slot console.log(first, third);
Array destructuring feels familiar to a C# programmer who deconstructs tuples: const [x, y] = point is the same idea as var (x, y) = point. JavaScript adds a trick with no direct C# equivalent — an empty slot in the pattern ([first, , third]) skips an element entirely without naming it.
Object destructuring (vs pattern matching)
var person = new { Name = "Alice", Age = 30 }; if (person is { Name: var name, Age: var age }) Console.WriteLine($"{name} is {age}");
const person = { name: "Alice", age: 30 }; const { name, age } = person; // names must match the property console.log(`${name} is ${age}`); const { name: fullName, role = "guest" } = person; // rename and default console.log(fullName, role);
Object destructuring plays a similar role to C#'s property pattern matching (person is { Name: var name }), pulling specific properties into local variables. Renaming uses a colon ({ name: fullName }), and a default value covers a missing or undefined property ({ role = "guest" }) — both are more concise than the equivalent C# pattern-matching syntax.
Spread in arrays (vs Concat)
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 in place — [...first, ...second] is the idiomatic replacement for LINQ's first.Concat(second).ToArray(). It also shallow-copies an array ([...original]) and, in a function call, expands array elements into individual arguments: Math.max(...numbers).
Spread in objects (vs with expressions)
var baseConfig = new Config("local", 5432); var updated = baseConfig with { Port = 6432 }; Console.WriteLine($"{updated.Host}:{updated.Port}"); record Config(string Host, int Port);
const baseConfig = { host: "local", port: 5432 }; const updated = { ...baseConfig, port: 6432 }; // later keys win console.log(`${updated.host}:${updated.port}`); console.log(baseConfig.port); // 5432 — the original is untouched
Spreading into a new object literal, with an overriding key listed afterward, achieves the same non-mutating "copy and update one field" pattern as C# record with expressions. This matters more in JavaScript than it might first appear, because plain objects are always mutable — spreading is the idiomatic way to produce an updated copy without touching the original.
Rest parameters (vs params)
Console.WriteLine(Sum(1, 2, 3, 4)); static int Sum(params int[] numbers) => numbers.Sum();
function sum(...numbers) { return numbers.reduce((total, n) => total + n, 0); } console.log(sum(1, 2, 3, 4)); const values = [5, 6, 7]; console.log(sum(...values)); // spread an array back into individual arguments
The rest parameter (...numbers) collects any extra arguments into a real array, the same role C#'s params int[] numbers plays. The two directions of ... mirror each other neatly: as a parameter it gathers arguments into an array; as a spread at the call site it expands an array back into arguments.
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, and JavaScript convention strongly favors braces even for single-statement branches. One important difference: JavaScript's if condition is implicitly coerced to a boolean rather than requiring one, so an empty string, 0, null, and undefined are all falsy — see the truthy/falsy concept below for the full table.
Truthy and falsy values
// C# requires an actual bool — no implicit conversion exists. bool isReady = true; if (isReady) Console.WriteLine("ready"); // if (0) { } // CS0029 — will not even compile
// JavaScript coerces ANY value to a boolean in a condition. const falsyValues = [0, "", null, undefined, NaN, false]; for (const value of falsyValues) { console.log(value, "is", value ? "truthy" : "falsy"); } console.log([] ? "truthy" : "falsy"); // truthy! — an empty array is still an object console.log({} ? "truthy" : "falsy"); // truthy! — same for an empty object
C# has no concept of truthy or falsy — a condition must be a genuine bool, full stop. JavaScript coerces any value, and only six values are falsy: 0, "", null, undefined, NaN, and false itself. Everything else — including an empty array [] and empty object {} — is truthy, which surprises programmers coming from a language with no implicit conversions at all.
== vs === (the classic JavaScript trap)
// C# has ONE == and it never coerces between unrelated types. Console.WriteLine(1 == 1); // true Console.WriteLine("1".Equals(1)); // CS1503 — will not compile; types must match
// JavaScript has TWO: == coerces, === does not. console.log(1 == "1"); // true — "1" is coerced to a number console.log(1 === "1"); // false — different types, no coercion console.log(0 == ""); // true — both coerce to 0 console.log(0 == "0"); // true console.log("" == "0"); // false — surprising! neither side coerces to the other console.log(null == undefined); // true — special-cased console.log(null === undefined); // false — different types
This is the single biggest gotcha for a C# programmer: C# has exactly one equality operator, and it never silently converts between unrelated types. JavaScript's == applies a web of type-coercion rules that are famously inconsistent — notice that 0 == "" and 0 == "0" are both true, yet "" == "0" is false. The universal fix is to always use === and never reason about =='s coercion table at all.
switch statement
var status = 2; var label = status switch { 1 => "active", 2 or 3 => "pending", _ => "unknown", }; Console.WriteLine(label);
const status = 2; let label; switch (status) { case 1: label = "active"; break; case 2: case 3: label = "pending"; break; default: label = "unknown"; } console.log(label);
C#'s modern switch expression (with =>) returns a value directly and has no fall-through. JavaScript only has the older switch statement — it does not produce a value, and execution falls through from one case to the next unless each ends with break. Grouping several values under one branch means stacking empty case labels, as shown for 2 and 3.
Nullish coalescing and optional chaining
string? name = null; string display = name ?? "Guest"; Console.WriteLine(display); string? upper = name?.ToUpper(); Console.WriteLine(upper ?? "(null)");
const name = null; const display = name ?? "Guest"; // ?? handles both null AND undefined console.log(display); const upper = name?.toUpperCase(); // optional chaining — undefined if nullish console.log(upper ?? "(null)");
?? and ?. work almost identically to C#'s versions of the same operators, and were in fact directly inspired by them. The one difference: JavaScript's ?? guards against both null and undefined at once, since JavaScript has both. The || operator is not a substitute for ?? — it also triggers on any other falsy value, like 0 or "".
for and for...of loops
for (int i = 0; i < 3; i++) Console.WriteLine(i); foreach (var letter in new[] { "a", "b", "c" }) Console.WriteLine(letter);
for (let i = 0; i < 3; i++) { console.log(i); } for (const letter of ["a", "b", "c"]) { console.log(letter); }
The C-style for loop is identical. C#'s foreach becomes for...of in JavaScript — note the keyword of, whose sibling for...in iterates property keys instead of values (including inherited ones) and is almost never what you want for an array. Prefer for...of or an array method like forEach.
Functions
Function declaration
static string Greet(string name) => $"Hello, {name}!"; Console.WriteLine(Greet("World"));
function greet(name) { return `Hello, ${name}!`; } console.log(greet("World"));
The function keyword replaces a C# method signature entirely — there are no parameter types, no return type, and no static keyword (all module-level functions are effectively static, since there is no enclosing class). Function declarations are "hoisted": they can be called before their definition appears later in the file, unlike a function assigned to a const.
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, b) => 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) read almost exactly like C# lambdas. For a multi-statement body, add braces and an explicit return: (n) => { const doubled = n * 2; return doubled; }. The single-expression form implicitly returns its value, just as a C# expression-bodied lambda does.
Default parameters
static string Greet(string name, string greeting = "Hello") => $"{greeting}, {name}!"; Console.WriteLine(Greet("Alice")); Console.WriteLine(Greet("Alice", "Hi"));
function greet(name, greeting = "Hello") { return `${greeting}, ${name}!`; } console.log(greet("Alice")); console.log(greet("Alice", "Hi"));
Default parameter values look identical to C#'s. One notable difference: a JavaScript default can be any expression, including one referencing an earlier parameter — function f(a, b = a * 2) — while C# restricts default values to compile-time constants. A default kicks in whenever the argument is omitted or explicitly passed as undefined.
No named arguments — the options-object idiom
MakeUser(name: "Alice", active: false, admin: true); static void MakeUser(string name, bool admin = false, bool active = true) => Console.WriteLine($"{name} admin={admin}");
function makeUser({ name, admin = false, active = true }) { console.log(`${name} admin=${admin}`); } // JS idiom: pass a single "options object" instead of named arguments. makeUser({ name: "Alice", active: false, admin: true });
JavaScript has no named-argument syntax like C#'s name: value at the call site. The established idiom is to accept a single destructured "options object" parameter, which gives the same order-independent, self-documenting calls. Defaults live right inside the destructuring pattern, exactly as shown.
No method overloading — dispatch by hand
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}"; }
// JavaScript has no overload resolution — one name, one function. function describe(value) { if (typeof value === "number") return `number: ${value}`; if (typeof value === "string") return `string: ${value}`; throw new TypeError("unsupported type"); } console.log(describe(42)); console.log(describe("hello"));
C#'s method overloading lets the compiler pick the right implementation based on argument types. JavaScript has no overload resolution mechanism at all — defining function describe twice simply replaces the first definition with the second. A single implementation must branch on typeof or instanceof itself to handle multiple argument shapes, or accept a flexible options object instead.
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) { return n => n * factor; } const triple = multiplier(3); console.log(triple(5)); // 15
Functions that return functions work the same way conceptually as C#'s Func<T, TResult>-returning methods. Because functions are ordinary values in JavaScript, no delegate type declaration is needed — the returned arrow function automatically captures factor from the enclosing scope. This pattern powers currying, memoization, and middleware idioms throughout JavaScript codebases.
Closures & this
Closures capture variables automatically
int factor = 3; Func<int, int> scale = n => n * factor; Console.WriteLine(scale(10)); // 30 factor = 5; Console.WriteLine(scale(10)); // 50 — captured by reference, sees the live variable
let factor = 3; const scale = n => n * factor; // captures 'factor' from the enclosing scope console.log(scale(10)); // 30 factor = 5; console.log(scale(10)); // 50 — sees the live, current value
Closures behave the same way in both languages: a C# lambda captures outer variables by reference, and so does a JavaScript closure — later changes to the captured variable are visible inside the function. This is the mechanism behind private state in JavaScript modules and factory functions, since there is no separate "captured variable" syntax to opt into, unlike some other languages.
C#'s this is always the instance
class Counter { private int count = 5; public void Show() => Console.WriteLine(this.count); public Action GetShower() => Show; // delegate keeps 'this' bound } var counter = new Counter(); var shower = counter.GetShower(); shower(); // 5 — always works, no matter how it's called
// (JavaScript comparison example — see the next concept) // In C#, 'this' inside an instance method is fixed at the moment the // method group is captured — it always refers to the receiving object, // regardless of how the resulting delegate is later invoked. console.log("C# 'this' cannot be detached from its instance.");
In C#, this inside an instance method always refers to the object the method was called on. Even when you capture a method as a delegate (Action shower = counter.Show;) and invoke it somewhere else entirely, this inside Show still points back to counter. This consistency is exactly what JavaScript's this does not guarantee, as the next concept shows.
JavaScript this is determined by how you call it
// (see previous concept for the C# side of this comparison) Console.WriteLine("C# 'this' is fixed to the instance — see JS side for the contrast.");
class Counter { count = 5; show() { console.log(this.count); } } const counter = new Counter(); counter.show(); // 5 — called AS A METHOD, this = counter const detached = counter.show; // detached(); // TypeError: Cannot read properties of undefined // // 'this' is lost — the function was called plainly const bound = counter.show.bind(counter); bound(); // 5 — bind() permanently pins 'this'
Unlike C#, JavaScript's this is not fixed to the object a method was defined on — it is determined entirely by how the function is called. Pulling a method off its object (const detached = counter.show) and calling it as a plain function loses this entirely, throwing a runtime error the moment the method body touches this. Fixes: call it as object.method(), use .bind(object) to pin the receiver permanently, or use an arrow function, which has no this of its own.
Arrow functions inherit this — the fix for callbacks
// C# lambdas always capture the enclosing 'this' — there is no // separate "regular function" form that behaves differently. class Counter { private int[] items = { 10, 20 }; private int baseValue = 100; public int[] Totals() => items.Select(n => n + baseValue).ToArray(); } Console.WriteLine(string.Join(", ", new Counter().Totals()));
class Counter { items = [10, 20]; baseValue = 100; totals() { // Arrow function inherits 'this' from totals() — behaves like C#. return this.items.map(n => n + this.baseValue); } totalsBroken() { // A regular function callback gets ITS OWN 'this' — undefined here. // return this.items.map(function (n) { return n + this.baseValue; }); // TypeError: Cannot read properties of undefined } } console.log(new Counter().totals());
This is the practical reason arrow functions exist. A regular JavaScript function expression, when used as a callback, gets its own this determined by how the callback itself is invoked — inside array.map(function (n) {...}), that this is undefined, not the enclosing object. An arrow function has no this of its own; it inherits the enclosing scope's this instead, matching the predictable behavior C#'s this always has. This is why arrow functions are the default choice for callbacks inside class methods.
Classes & Prototypes
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 { #name; constructor(name) { this.#name = name; } speak() { return `${this.#name} makes a sound`; } } const animal = new Animal("Rex"); console.log(animal.speak());
JavaScript class syntax reads similarly to C#: a constructor method, this. for instance members, and new to instantiate. There is no automatic property syntax — fields must be assigned by hand — and every method is implicitly virtual/overridable; there is no virtual keyword because it is simply always the case.
Prototypal inheritance vs class-based OOP
// C# inheritance is class-based: types are fixed at compile time, // and the inheritance chain is baked into the type system. Console.WriteLine(new Dog().Speak()); class Animal { public virtual string Speak() => "..."; } class Dog : Animal { public override string Speak() => "Woof"; }
// Under the hood, JS class syntax is sugar over PROTOTYPES — // every object has a hidden link to another object it delegates to. function makeAnimal(name) { return { name, speak() { return "..."; }, }; } const genericAnimal = makeAnimal("Rex"); const dog = Object.create(genericAnimal); // dog's prototype is genericAnimal dog.speak = function () { return `${this.name} says Woof`; }; console.log(dog.speak()); // Woof — dog's own method console.log(Object.getPrototypeOf(dog) === genericAnimal); // true
C#'s object model is class-based: a type's shape and inheritance chain are fixed by the compiler. JavaScript's object model is fundamentally different — prototypal: every object has a live, mutable link (its prototype) to another object it delegates missing properties and methods to, and Object.create() builds that link directly. The class keyword introduced in ES6 is syntax sugar over this same prototype-chain machinery — it does not add a genuinely different object model underneath, just a more familiar-looking syntax on top of one.
ES6 class syntax is sugar over prototypes
Console.WriteLine(new Animal().Speak()); class Animal { public virtual string Speak() => "..."; }
class Animal { speak() { return "..."; } } const animal = new Animal(); console.log(animal.speak()); // The class's methods live on Animal.prototype, not on each instance: console.log(typeof Animal.prototype.speak); // "function" console.log(Object.getPrototypeOf(animal) === Animal.prototype); // true console.log(animal.hasOwnProperty("speak")); // false — inherited, not own
When JavaScript compiles a class body, instance methods like speak() are placed on Animal.prototype, a single shared object — not copied onto each instance. Every Animal instance has a hidden prototype link to that shared object, so animal.speak() works by JavaScript walking up the prototype chain until it finds speak. This is invisible when using class normally, but understanding it explains why prototype methods are shared and memory-efficient, and why modifying Animal.prototype at runtime affects every existing instance — something C#'s sealed-at-compile-time type system never allows.
Private fields
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 — enforced at runtime deposit(amount) { this.#balance += amount; } get balance() { return this.#balance; } } const account = new BankAccount(); account.deposit(100); console.log(account.balance); // console.log(account.#balance); // SyntaxError — inaccessible outside the class
JavaScript's native #field syntax gives genuinely private fields, enforced by the runtime rather than a compile-time-only convention — unlike C#'s private, which is a language-level access modifier that JavaScript historically lacked. The get keyword defines an accessor called without parentheses, the same as a C# property.
Inheritance and super
Console.WriteLine(new Dog("Rex").Speak()); class Animal { protected string name; public Animal(string name) { this.name = name; } public virtual string Speak() => "..."; } class Dog : Animal { public Dog(string name) : base(name) {} public override string Speak() => $"{name} says Woof"; }
class Animal { constructor(name) { this.name = name; } speak() { return "..."; } } class Dog extends Animal { speak() { return `${this.name} says Woof`; } } console.log(new Dog("Rex").speak());
Inheritance uses the same extends keyword C# does not have (C# uses : instead). To call a parent constructor or method, JavaScript uses super(...) / super.speak() rather than base(...) / base.Speak(). One strict rule: a subclass constructor must call super() before touching this at all, since the parent constructor is responsible for initializing the instance.
Static members
Console.WriteLine(MathUtil.Square(5)); Console.WriteLine(MathUtil.Pi); static class MathUtil { public const double Pi = 3.14159; public static int Square(int n) => n * n; }
class MathUtil { static pi = 3.14159; static square(n) { return n * n; } } console.log(MathUtil.square(5)); console.log(MathUtil.pi);
JavaScript marks both static methods and static fields with the static keyword and accesses them through the class name with a dot — the same syntax C# uses. Unlike C#, a JavaScript class does not need to be marked static as a whole; individual members opt in one at a time, so a class can freely mix static and instance members.
Modules
Modules: import / export (vs namespaces)
namespace MathHelpers; public static class MathHelperFunctions { public static int Square(int n) => n * n; } // In another file, after "using MathHelpers;": // Console.WriteLine(MathHelperFunctions.Square(5));
// mathHelpers.js export function square(n) { return n * n; } export const PI = 3.14159; // main.js import { square, PI } from "./mathHelpers.js"; console.log(square(5), PI);
JavaScript ES modules are explicit about what crosses file boundaries: each name must be individually exported, and an importer names exactly what it needs with import { ... } from "..." — closer in spirit to C#'s access modifiers than to using directives, since nothing is visible outside a module unless deliberately exported. There is no namespace-style dotted access; each file is its own independent scope. This example cannot run in the single-file test sandbox, but it is the backbone of every real JavaScript project.
Packages: npm vs NuGet
// C# packages come from NuGet: // dotnet add package Newtonsoft.Json // using Newtonsoft.Json; // var json = JsonConvert.SerializeObject(new { Name = "Alice" }); Console.WriteLine("C# uses NuGet + .csproj package references");
// JavaScript packages come from npm: // npm install lodash // import groupBy from "lodash/groupBy"; console.log("JS uses npm + package.json dependencies");
Dependencies are installed from npm, the JavaScript package registry analogous to NuGet, into a local node_modules folder — with package.json playing the role of a .csproj's package references. Unlike NuGet packages, which are typically compiled assemblies, most npm packages ship as plain JavaScript source, which is part of why the ecosystem moves so fast and occasionally breaks in surprising ways.
Error Handling
try / catch / finally
static int ParsePort(string text) { if (!int.TryParse(text, out int port)) throw new ArgumentException($"bad port: {text}"); return port; } try { Console.WriteLine(ParsePort("abc")); } catch (ArgumentException error) { Console.WriteLine($"Error: {error.Message}"); } finally { Console.WriteLine("done"); }
function parsePort(text) { if (!/^\d+$/.test(text)) { throw new Error(`bad port: ${text}`); } return Number(text); } try { console.log(parsePort("abc")); } catch (error) { console.log(`Error: ${error.message}`); } finally { console.log("done"); }
The structure is the same — try / catch / finally — and the shape reads familiarly to a C# programmer. The key difference is that JavaScript's catch has no typed catch clauses: unlike C#'s catch (ArgumentException error), which filters by exception type, a JavaScript catch (error) block always catches everything and must inspect error itself (with instanceof) to decide what to do. The message property is .message, not C#'s .Message.
Custom error types
try { throw new PaymentException("card declined"); } catch (PaymentException error) { Console.WriteLine($"Caught: {error.Message}"); } class PaymentException : Exception { public PaymentException(string message) : base(message) {} }
class PaymentError extends Error { constructor(message) { super(message); this.name = "PaymentError"; } } try { throw new PaymentError("card declined"); } catch (error) { if (error instanceof PaymentError) { console.log(`Caught: ${error.message}`); } else { throw error; // re-throw anything this block does not handle } }
Custom errors subclass the built-in Error, the way a C# custom exception subclasses Exception. Two conventions matter here: call super(message) so the message and stack trace are set up correctly, and assign this.name so the error identifies itself when logged. Because catch has no typed clauses, you distinguish your custom error with instanceof and re-throw anything unexpected — the same defensive pattern a C# catch-all block requires.
You can throw anything, but should not
// C# only permits throwing objects derived from Exception. try { throw new Exception("must derive from Exception"); } catch (Exception error) { Console.WriteLine(error.Message); }
// JS lets you throw ANY value — but you should not. try { throw "a bare string"; // legal, but loses the stack trace } catch (error) { console.log(typeof error, error); // string a bare string } // Always throw an Error (or subclass) so .message and .stack exist.
C#'s type system only permits throwing an object that derives from Exception. JavaScript places no such restriction on throw — a string, a number, or a plain object can all be thrown. This is a trap: a thrown non-Error value has no .message or .stack, so any code that assumes those properties exist will break. The universal rule is to always throw an Error or a subclass of it.
Async & Promises
One thread and an event loop (vs the thread pool)
// C# async/await schedules continuations on the THREAD POOL — // multiple threads can genuinely run in parallel. Console.WriteLine("first"); await Task.Delay(0); // yields, may resume on a different thread Console.WriteLine("third — but a background thread could run concurrently");
// JS runs on a SINGLE thread with an event loop — nothing runs // in true parallel; deferred work is queued and interleaved. console.log("first"); setTimeout(() => console.log("deferred"), 0); console.log("third"); // Output order: first, third, deferred // — the timer callback runs only after ALL synchronous code finishes.
This is the deepest model difference behind the syntax similarity. C#'s async/await schedules continuations on the .NET thread pool — genuine parallel execution is possible. JavaScript runs on a single thread with an event loop: nothing ever executes truly in parallel within one JavaScript context, and "async" work — timers, network responses, promise resolutions — is only ever interleaved with other JavaScript code, never running at the same instant as it. Understanding this ordering is essential to everything that follows.
Promises vs Task — no separate type, always hot
// C#: a Task can be created without starting (cold, via a TaskCompletionSource) // or is typically already running once you have a reference to it. static Task<string> FetchNameAsync() => Task.FromResult("Alice"); var task = FetchNameAsync(); Console.WriteLine(await task);
// JS: a Promise's executor runs IMMEDIATELY and synchronously — // there is no separate "not yet started" state to opt out of. function fetchName() { return new Promise(resolve => { console.log("executor runs right now, synchronously"); setTimeout(() => resolve("Alice"), 10); }); } const promise = fetchName(); // the console.log above already happened promise.then(name => console.log(name));
JavaScript has no distinct Task type — a Promise fills that role, but with one behavioral quirk worth knowing: a Promise's executor function runs immediately and synchronously the moment new Promise(...) is called — Promises are always "hot," already in progress before you ever call .then(). There is no cold/not-yet-started state the way a manually constructed Task can have; work begins the instant the Promise is created.
async / await — a genuine bridge point
static Task<string> FetchNameAsync(int id) => Task.FromResult("Alice"); static async Task<string> GreetAsync(int id) { string name = await FetchNameAsync(id); // pause until the Task completes return $"Hello, {name}"; } Console.WriteLine(await GreetAsync(1));
function fetchName(id) { return Promise.resolve("Alice"); } async function greet(id) { const name = await fetchName(id); // pause until the Promise settles return `Hello, ${name}`; } (async () => { console.log(await greet(1)); })();
Here the two languages are nearly identical, and it is genuinely the smoothest concept to carry over: async marks a function as asynchronous, and await pauses until the awaited value resolves, letting asynchronous code read almost exactly like synchronous code in both languages. The differences are underneath, not in the syntax: JavaScript has no Task type (just Promise), no thread pool to schedule onto, and every async function — like every C# async Task method — always returns a value that must itself be awaited or chained.
Concurrent work: Promise.all vs Task.WhenAll
static Task<int> FetchPriceAsync(string item) => Task.FromResult(item.Length * 10); var appleTask = FetchPriceAsync("apple"); var pearTask = FetchPriceAsync("pear"); var prices = await Task.WhenAll(appleTask, pearTask); Console.WriteLine(prices.Sum());
function fetchPrice(item) { return Promise.resolve(item.length * 10); } const [apple, pear] = await Promise.all([ fetchPrice("apple"), fetchPrice("pear"), ]); console.log(apple + pear);
Promise.all plays exactly the role of C#'s Task.WhenAll: kick off several asynchronous operations, then await all of them together so the total wait is the slowest one rather than the sum of each — even though, underneath, JavaScript is interleaving on a single thread rather than genuinely running them on separate ones. Promise.allSettled is the variant that does not reject as soon as the first operation fails, similar in spirit to inspecting each Task's status individually.
Standard Library
JSON
using System.Text.Json; var payload = new { Name = "Alice", Roles = new[] { "admin", "user" } }; var json = JsonSerializer.Serialize(payload); Console.WriteLine(json); var parsed = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json); Console.WriteLine(parsed!["Name"]);
const payload = { name: "Alice", roles: ["admin", "user"] }; const json = JSON.stringify(payload); console.log(json); const parsed = JSON.parse(json); console.log(parsed.name);
JSON is native to JavaScript — it is, quite literally, JavaScript object-literal syntax — so JSON.stringify and JSON.parse need no serializer configuration, attribute annotations, or generic type parameter the way C#'s System.Text.Json does. JSON.stringify(value, null, 2) pretty-prints with two-space indentation.
Sorting
var words = new List<string> { "banana", "apple", "cherry" }; words.Sort(); Console.WriteLine(string.Join(", ", words)); var numbers = new List<int> { 10, 2, 33, 4 }; numbers.Sort(); Console.WriteLine(string.Join(", ", numbers));
const words = ["banana", "apple", "cherry"]; words.sort(); // mutates, alphabetical — fine for strings console.log(words); const numbers = [10, 2, 33, 4]; numbers.sort((a, b) => a - b); // MUST pass a comparator for numbers! console.log(numbers); // [2, 4, 10, 33]
A genuine trap for a C# programmer: JavaScript's default sort() converts every element to a string before comparing, so [10, 2, 33, 4] sorts lexicographically to [10, 2, 33, 4]"10" sorts before "2". C#'s List<int>.Sort() has no such trap because it is generic and numerically aware by default. For numbers in JavaScript, always supply a comparator: (a, b) => a - b. Like C#'s Sort(), JavaScript's sort() mutates the array in place.
Membership checks
var fruits = new List<string> { "apple", "banana" }; Console.WriteLine(fruits.Contains("apple")); // true var ages = new Dictionary<string, int> { { "Alice", 30 } }; Console.WriteLine(ages.ContainsKey("Alice")); // true Console.WriteLine(ages.ContainsKey("Bob")); // false
const fruits = ["apple", "banana"]; console.log(fruits.includes("apple")); // true const ages = new Map([["Alice", 30]]); console.log(ages.has("Alice")); // true console.log(ages.has("Bob")); // false const agesObject = { Alice: 30 }; console.log("Alice" in agesObject); // true console.log(agesObject.Bob === undefined); // true — no exception on a missing key
For arrays, includes is the equivalent of Contains. For a Map, has mirrors ContainsKey closely. For a plain object used as a lookup, the in operator checks for a key's presence — and reading a missing property simply returns undefined rather than throwing the way an indexer would on a missing Dictionary key without TryGetValue.
Number formatting
Console.WriteLine((3.14159).ToString("F2")); // 3.14 Console.WriteLine((1234567.5).ToString("N2")); // 1,234,567.50 Console.WriteLine(Math.Sign(3 - 5)); // -1
console.log((3.14159).toFixed(2)); // "3.14" — returns a STRING console.log((1234567.5).toLocaleString("en-US")); // "1,234,567.5" console.log(Math.sign(3 - 5)); // -1
toFixed(n) rounds to a fixed number of decimal places but returns a string, not a number — a common source of bugs when the result is used in further arithmetic without converting back with Number() or a unary +. toLocaleString() handles thousands-grouping and locale formatting, filling the role of C#'s "N" format specifier.
⚠ Gotchas for C# Programmers
No safety net — not even JSDoc
// C# rejects this before the program ever runs: // int count = "not a number"; // CS0029 — compile error int realCount = 42; Console.WriteLine(realCount);
/** * @param {number} count */ function report(count) { console.log(`Count: ${count}`); } report("not a number"); // JSDoc says number — the runtime does not care AT ALL report(count); // ReferenceError: count is not defined — also not caught until run
Even with JSDoc type annotations (@param {number} count), plain JavaScript has zero runtime type enforcement — the annotation is purely advisory documentation for editor tooling, never checked by the JavaScript engine itself. Only a separate static analysis pass (an editor's TypeScript-powered checker, or an actual TypeScript build) can catch a JSDoc type mismatch, and only before deployment, never at runtime. Coming from C#'s compiler-enforced guarantees, this is the single biggest adjustment: nothing stops a type error from reaching production.
this loses its binding as a callback
// In C#, passing a method as a delegate NEVER loses 'this': class Counter { private int count = 5; public void Show() => Console.WriteLine(count); } var counter = new Counter(); Action action = counter.Show; // still bound to counter action(); // 5 — always safe
class Counter { count = 5; show() { console.log(this.count); } } const counter = new Counter(); const buttonHandler = counter.show; // detached from counter! // buttonHandler(); // TypeError — 'this' is undefined here // Fix 1: bind it. const bound = counter.show.bind(counter); bound(); // 5 // Fix 2: wrap in an arrow function, which inherits the surrounding 'this'. const wrapped = () => counter.show(); wrapped(); // 5
This is the single most common runtime surprise for a C# programmer: passing object.method as a callback (to setTimeout, an event listener, an array method) silently detaches this — C# delegates never do this. Always call methods through the object (counter.show()), .bind() the receiver explicitly, or wrap the call in an arrow function.
The == vs === coercion trap
// C# has exactly one == and it never silently coerces types. Console.WriteLine("1".Equals(1)); // does not even compile — CS1503 Console.WriteLine(1 == 1); // true — only compares like types
console.log(0 == ""); // true console.log(0 == "0"); // true console.log("" == "0"); // false console.log(false == "0"); // true console.log(null == 0); // false — even though both "seem" empty console.log(NaN == NaN); // false — NaN never equals anything, even itself! // The fix, always: console.log(0 === ""); // false — no coercion, immediately clear console.log(Number.isNaN(NaN)); // true — the correct way to test for NaN
Repeated here as its own gotcha because it deserves to be memorized: JavaScript's == coercion table has no C# analogue and is not fully consistent even internally — memorizing it is not worth the effort. Always use ===, and always use Number.isNaN() rather than == or === to test for NaN, since NaN famously does not equal itself under any comparison operator.
All numbers are floating point
int count = 3; decimal price = 19.99m; long bigNumber = 9_007_199_254_740_993L; // fits exactly — long is 64-bit integer Console.WriteLine(count); Console.WriteLine(price); Console.WriteLine(bigNumber);
const count = 3; // number — same type as everything else const price = 19.99; // number — no decimal type; rounding errors are possible const bigNumber = 9007199254740993; // number — but this LOSES precision! console.log(count); console.log(price); console.log(bigNumber); // 9007199254740992 — silently wrong! console.log(bigNumber === 9007199254740993); // false console.log(9007199254740993n); // bigint literal preserves it exactly
C# distinguishes int, long, float, double, and decimal for good reasons — each has different precision and range guarantees. JavaScript's single number type is a 64-bit float, which means integers beyond Number.MAX_SAFE_INTEGER (253 − 1) silently lose precision with no warning or exception — the value above rounds to the nearest representable float. Use the separate bigint type (the n suffix) for integers that must stay exact beyond that range, and never use plain number for currency without a dedicated library.
Hoisting of var and function declarations
// C# has no hoisting — a variable must be declared before use, // full stop, and the compiler enforces it. int total = 0; Console.WriteLine(total);
console.log(typeof hoistedVar); // "undefined" — NOT an error! var hoistedVar = 5; // the DECLARATION is hoisted, the assignment is not sayHello(); // works — function declarations are FULLY hoisted function sayHello() { console.log("hello"); } // sayArrow(); // ReferenceError — const/let are NOT hoisted the same way const sayArrow = () => console.log("hi");
C# requires a variable to be declared before any use — there is no ambiguity. JavaScript's legacy var declarations are "hoisted": the declaration itself is moved to the top of the enclosing function during compilation, but the assignment stays in place, so reading a var before its assignment line gives undefined rather than an error. Function declarations (function name() {}) are hoisted completely, body and all, so they can be called before they appear in the file — but a function stored in a const/let is not, and referencing it early throws a ReferenceError. Modern code sidesteps the whole issue by using let/const and declaring functions before use.
Objects and arrays are always references
// C# has real value types (struct) — this genuinely copies: var original = new Point { X = 1, Y = 2 }; var copy = original; copy.X = 99; Console.WriteLine(original.X); // 1 — unaffected, structs copy by value struct Point { public int X, Y; }
// JS has NO value-type equivalent of a struct — objects and // arrays are ALWAYS reference types, with no opt-out. const original = { x: 1, y: 2 }; const copy = original; // copies the REFERENCE, not the object copy.x = 99; console.log(original.x); // 99 — changed too! same underlying object const realCopy = { ...original }; // spread makes a shallow copy instead realCopy.x = 5; console.log(original.x); // still 99 — unaffected this time
C# gives you a genuine choice between reference types (class) and value types (struct), where a struct assignment truly copies the data. JavaScript has no value-type equivalent for objects or arrays at all — every object and array is always a reference, and copy = original makes both names point at the exact same underlying data. To copy, spread into a new literal ({ ...original } or [...list]) for a shallow clone, or use structuredClone(original) for a deep one. Only primitives (numbers, strings, booleans, null, undefined, bigint) copy by value.