PONYλM2Modula-2

JavaScript.CodeCompared.To/C#

An interactive executable cheatsheet comparing JavaScript and C#

JavaScript C# 14 (.NET 10)
Hello World & Running
Hello, World
One line each. C# 9 added top-level statements, so the class Program { static void Main() } ceremony a JavaScript developer has heard about is no longer required.
console.log("Hello, World!");
Console.WriteLine("Hello, World!");
The similarity is real but shallow, and it hides the one structural difference worth knowing immediately: executable statements must come before any type declaration in a top-level program. So a file that defines a class and then uses it has to put the usage first and the class at the bottom — which is the reverse of what a JavaScript developer expects from hoisting, and is enforced by the compiler as CS8803. Every example on this page is written that way.
The build step
Both print the same number. The difference is the line that never runs — uncomment it on the right and the program stops existing, while the JavaScript version ships the same mistake happily.
// node report.js — the file is parsed and run. A misspelled name // inside a branch you never take is not an error until that branch // runs, which may be in production. let total = 0; for (const value of [3, 1, 4, 1, 5]) total += value; if (total > 1000) { console.log(mispelledName); // never evaluated, never complained about } console.log("total:", total);
// dotnet run — the compiler reads EVERY branch first, and refuses to // produce a program if any name in any of them is wrong. int total = 0; foreach (var value in new[] { 3, 1, 4, 1, 5 }) total += value; if (total > 1000) { // Console.WriteLine(mispelledName); // ← uncomment: it will not build } Console.WriteLine($"total: {total}");
This is the trade in one row, and it cuts both ways. C# finds the typo, and C# also makes you wait for a compiler every time you want to see anything. A JavaScript developer usually underestimates how much of their working rhythm was "save and look" — which costs nothing in Node and a few seconds in .NET. Hot reload closes some of that gap, and dotnet watch is the closest thing to nodemon.
Types That Survive To Runtime
A variable has one type, forever
var is the word that misleads JavaScript developers most. It does not mean "any type" — it asks the compiler to write the type for you, and the variable is then that type for the rest of its life.
let count = 42; count = "now a string"; // perfectly legal const ratio = 0.5; const label = "widget"; const enabled = true; console.log(count, ratio, label, enabled);
int count = 42; // count = "now a string"; ← uncomment: the build fails here double ratio = 0.5; string label = "widget"; bool enabled = true; var inferred = 42; // still int — var is inference, not dynamic Console.WriteLine($"{count} {ratio} {label} {enabled} {inferred}");
C# does have dynamic, which genuinely defers everything to run time, and it is almost never the right answer — it exists for interop with COM and with dynamic languages. The everyday tools are var for inference, object when something really can be anything, and record/generics when a type varies in a structured way. Note that const in C# means a compile-time constant, so JavaScript's const is closer to C#'s readonly — which applies only to fields; a C# local cannot be made read-only at all.
🚨 An object's shape is fixed
This is the row that decides how the rest of the page reads. An instance of a C# class is a fixed block of memory laid out at compile time, so there is nowhere for a new property to go and nothing to delete. (ExpandoObject is the deliberate exception: a dynamic bag that takes new members at run time.)
const point = { x: 1, y: 2 }; point.z = 3; // a new property, whenever you like delete point.y; // and gone again // A whole method, added to every string in the program: String.prototype.shout = function () { return this.toUpperCase() + "!"; }; console.log(point.z, "hi".shout());
var point = new Point { X = 1, Y = 2 }; // point.Z = 3; ← 'Point' has no definition for 'Z'; will not build // There is no delete, because there is nothing to remove: the object // is a fixed block of memory whose size the compiler computed. Console.WriteLine($"{point.X},{point.Y}"); // Extension methods are the nearest thing to prototype patching, and // they are compile-time sugar: Shout is a static method, resolved by // the compiler, and it cannot see private state or replace anything. Console.WriteLine("hi".Shout()); class Point { public int X; public int Y; } static class StringExtensions { public static string Shout(this string text) => text.ToUpper() + "!"; }
The JavaScript techniques that depend on adjusting an ordinary object later have no direct equivalent: no monkey patching, no adding a method to a built-in, no mixin applied at run time. (Mocking libraries replace a method by generating a subclass, and reflection can build new types at run time, but neither changes an existing object.) Extension methods look similar and are not — they are static methods the compiler rewrites your call into, so they cannot override anything or reach private state. In exchange, a property access is an offset rather than a hash lookup, and a misspelled property is a build error rather than undefined.
Numbers stop being one type
🚨 The line to look at is 7 / 2. Two integers divide to an integer in C#, so the answer is 3 — silently discarding the fraction, in a language that otherwise catches everything.
// Every number is a 64-bit float. Integers are exact only up to 2^53. console.log(0.1 + 0.2); console.log(7 / 2); console.log(Number.MAX_SAFE_INTEGER + 2 === Number.MAX_SAFE_INTEGER + 3); console.log(typeof 42, typeof 42n);
// int, long, double, float, decimal — and the operators mean // different things depending on which you have. Console.WriteLine(0.1 + 0.2); // double: the same rounding Console.WriteLine(7 / 2); // int division: 3, not 3.5 Console.WriteLine(7 / 2.0); // double division: 3.5 Console.WriteLine(0.1m + 0.2m); // decimal: exact, for money Console.WriteLine(long.MaxValue);
The types that matter day to day: int for counting, long for identifiers and timestamps, double for measurement and decimal for money — the last has no JavaScript equivalent short of a library and is exactly right for currency, since 0.1m + 0.2m is exactly 0.3. The shared surprise is that double arithmetic rounds identically in both languages, because both are IEEE 754. Integer overflow wraps silently unless you write checked.
Equality gets simpler
One ==, no coercion table, and no need for a linter rule about which to use. Comparing a number with a string is a compile error rather than a surprise (numeric types still widen: 1 == 1.0 is true).
console.log(1 == "1"); // true — coercion console.log(1 === "1"); // false — strict console.log([] == false); // true — the famous one console.log(NaN === NaN); // false console.log({ a: 1 } === { a: 1 }); // false: different objects
// There is no ==/=== distinction, because there is no coercion to // protect yourself from: comparing an int with a string does not // compile at all. Console.WriteLine(1.Equals(1)); Console.WriteLine(double.NaN == double.NaN); // False, same as JS // For classes, == compares references unless the type overrides it. // For records, == compares VALUES, generated for you. Console.WriteLine(new Point(1, 2) == new Point(1, 2)); record Point(int X, int Y);
What replaces the JavaScript rules is a different question: reference or value. == on a class compares references (two objects with identical contents are not equal), on a record compares values (the compiler generates it), and on a plain struct does not compile at all unless the struct declares it (Equals compares its fields). NaN != NaN survives, because that is IEEE 754 rather than a language decision. And Equals and GetHashCode must be overridden together for a class used as a dictionary key — which is what records do for you, and the reason to prefer them.
Value Types & Reference Types
Some things are copied now
JavaScript has one rule — everything is a reference. C# has two, and the keyword you used to declare the type decides which: struct is copied, class is referenced.
// Every object is a reference. Assignment never copies. const first = { x: 1 }; const second = first; second.x = 99; console.log(first.x); // 99 — there was only ever one object const numbers = [1, 2, 3]; const alias = numbers; alias.push(4); console.log(numbers.length); // 4
// A struct is a VALUE: assignment copies the whole thing. var firstPoint = new Point { X = 1 }; var secondPoint = firstPoint; secondPoint.X = 99; Console.WriteLine(firstPoint.X); // 1 — two separate points // A class is a reference, and behaves the way JavaScript does. var firstNode = new Node { Value = 1 }; var secondNode = firstNode; secondNode.Value = 99; Console.WriteLine(firstNode.Value); // 99 struct Point { public int X; } class Node { public int Value; }
The practical guidance is that you will mostly write class and mostly get JavaScript's behavior, so this is a smaller change than it first looks. Where it matters is that several built-in types are structs — int, bool, DateTime, Guid, every tuple and every enum — so passing one to a method copies it, and modifying the copy changes nothing. The rule of thumb from the .NET guidelines: use a struct for a small immutable value that behaves like a number, and a class for everything else.
Records are the object literal you wanted
with is the spread operator: it copies the record and changes the named properties. record generates the constructor, the properties, value equality, a hash code and a readable ToString from one line.
const point = { x: 1, y: 2 }; const moved = { ...point, y: 5 }; // spread to copy-with-changes console.log(JSON.stringify(moved)); console.log(point.x === moved.x && point.y === moved.y); // false
var point = new Point(1, 2); var moved = point with { Y = 5 }; // 'with' is the spread Console.WriteLine(moved); // ToString is generated Console.WriteLine(point == moved); // false — value equality, generated record Point(int X, int Y);
For a JavaScript developer this is the single most worthwhile C# feature to adopt early, because it is the closest thing to an object literal that the type system takes seriously — and it removes the boilerplate that made older C# feel heavy. The differences from a literal: the shape is declared once and fixed, the properties are immutable by default, and equality compares contents rather than identity. record struct gives you the same thing as a value type.
Strings
Template literals become interpolation
The $ prefix is the backtick, and {} is ${}. The format specification after a colon — :F2, :N0, :yyyy-MM-dd — has no JavaScript equivalent and replaces a call to toFixed or a date library.
const name = "widget"; const count = 7; console.log(`${count} x ${name}`); console.log(`${(12.5).toFixed(2)}`); console.log(`multi line`);
var name = "widget"; var count = 7; Console.WriteLine($"{count} x {name}"); Console.WriteLine($"{12.5:F2}"); Console.WriteLine(""" multi line """);
Raw string literals with three quotes are the modern multi-line form (a verbatim @"…" string can span lines too), and they strip the indentation to match the closing delimiter, which template literals do not. The other difference is that a C# string is UTF-16 and immutable, like JavaScript's — so the surrogate-pair problem is the same in both, and an emoji counts as two in Length. Use StringBuilder for repeated concatenation, which is the equivalent of pushing to an array and joining.
The methods you know, capitalized
Almost everything transfers with a capital letter: split is Split, includes is Contains, trim is Trim. Every public member in .NET is PascalCase, which is the naming convention to absorb immediately.
const line = "alpha,beta,gamma"; console.log(line.split(",")); console.log(line.toUpperCase()); console.log(line.includes("beta")); console.log(line.replace("beta", "BETA")); console.log(" padded ".trim()); console.log(["a", "b"].join("|"));
var line = "alpha,beta,gamma"; Console.WriteLine(string.Join(", ", line.Split(','))); Console.WriteLine(line.ToUpper()); Console.WriteLine(line.Contains("beta")); Console.WriteLine(line.Replace("beta", "BETA")); Console.WriteLine(" padded ".Trim()); Console.WriteLine(string.Join("|", new[] { "a", "b" }));
Two that move rather than rename. join is string.Join — a static method taking the separator first, which is backwards from array.join(separator). And replace in C# replaces every occurrence, where JavaScript's replaces only the first unless you pass a global regular expression; replaceAll is what C# does by default. Note ',' in single quotes: that is a char, a distinct type from a one-character string.
Collections
Arrays split into two things
The one JavaScript array becomes two: int[] is a fixed-size block and List<int> is the growable one. You will write List<T> nearly always.
const values = [3, 1, 4]; values.push(1); values.unshift(9); console.log(values.length, values[0], values.at(-1)); console.log(values); // A JavaScript array is a growable, mixed-type, sparse-capable object. values[10] = 99; console.log(values.length); // 11 — holes and all
// A C# array is FIXED SIZE and single-typed. int[] fixedSize = { 3, 1, 4 }; Console.WriteLine($"{fixedSize.Length} {fixedSize[0]}"); // List<T> is what you actually want: growable, still single-typed. var values = new List<int> { 3, 1, 4 }; values.Add(1); values.Insert(0, 9); Console.WriteLine($"{values.Count} {values[0]} {values[^1]}"); Console.WriteLine(string.Join(", ", values));
Three habits to retire. There are no holes and no sparse arrays — an index past the end throws IndexOutOfRangeException rather than growing the array. A collection holds one type, so a mixed array needs List<object> and a cast on the way out, which is usually a sign the design wants a record instead. And Count is the property on a List while Length is the one on an array, which trips up everybody. ^1 is at(-1), and slicing is values[1..3].
An object-as-dictionary becomes Dictionary
The JavaScript reflex of using a plain object as a lookup table has no C# equivalent, and that is a good thing: Dictionary<TKey, TValue> is what Map is, with no prototype keys to collide with and no key coercion to strings.
// The two habits JavaScript gives you: const counts = { apple: 2, pear: 5 }; counts.plum = 1; console.log(counts.apple, "fig" in counts); const map = new Map([["apple", 2]]); map.set("pear", 5); console.log(map.get("apple"), map.has("fig")); for (const [key, value] of map) console.log(key, value);
// One habit: Dictionary<TKey, TValue>. An ordinary object is not a // bag of keys, because its shape is fixed (ExpandoObject is the // rarely-used exception). var counts = new SortedDictionary<string, int> { ["apple"] = 2, ["pear"] = 5 }; counts["plum"] = 1; Console.WriteLine($"{counts["apple"]} {counts.ContainsKey("fig")}"); Console.WriteLine(counts.TryGetValue("fig", out var found) ? found : 0); foreach (var (key, value) in counts) Console.WriteLine($"{key} {value}");
🚨 The trap is the indexer. counts["fig"] on a missing key throws KeyNotFoundException rather than returning undefined — which is safer and catches JavaScript developers constantly. TryGetValue is the idiom for "get it if it is there", and the out var found declares the variable inline. Dictionary is the hash map; SortedDictionary is used here only so the iteration order is defined, since neither guarantees insertion order the way a JavaScript Map does.
Destructuring, mostly intact
Tuples and deconstruction cover most of what destructuring did, including returning two values from a method — (int Low, int High) is a real return type with named fields.
const [first, second] = [1, 2]; const { x, y } = { x: 10, y: 20 }; function minMax(values) { return [Math.min(...values), Math.max(...values)]; } const [low, high] = minMax([4, 9, 1]); console.log(first, second, x, y, low, high);
var (first, second) = (1, 2); // tuple deconstruction var point = new Point(10, 20); var (x, y) = point; // records deconstruct too (int Low, int High) MinMax(int[] values) => (values.Min(), values.Max()); var (low, high) = MinMax(new[] { 4, 9, 1 }); Console.WriteLine($"{first} {second} {x} {y} {low} {high}"); record Point(int X, int Y);
Destructuring by property name is written as a pattern — person is { Name: var name, Age: var age } — and a rest element as a slice in a list pattern, values is [var head, .. var rest]. What does not carry over is default values in the pattern. The spread operator has partial equivalents — params for arguments, collection expressions ([..first, ..second]) in C# 12 for combining collections, and with for copy-and-change on records.
LINQ
map, filter and reduce, renamed
The translation table is short: filter is Where, map is Select, reduce is Aggregate, and find is FirstOrDefault. The arrow syntax is identical.
const values = [1, 2, 3, 4, 5, 6]; const total = values .filter(value => value % 2 === 0) .map(value => value * value) .reduce((sum, value) => sum + value, 0); console.log(total);
var values = new List<int> { 1, 2, 3, 4, 5, 6 }; var total = values .Where(value => value % 2 == 0) .Select(value => value * value) .Sum(); Console.WriteLine(total);
The important difference is that LINQ is lazy: Where and Select build a query rather than an array, and nothing runs until something consumes it — a foreach, a Sum, or an explicit ToList(). That is closer to a generator than to an array method, and it means chaining ten operations makes one pass rather than ten intermediate arrays. It also means a query can be evaluated twice by accident if you enumerate it twice, which is the one new hazard.
Grouping and sorting, without the boilerplate
GroupBy is the operation JavaScript spent twenty years without — Object.groupBy only arrived recently — and it is the clearest single argument for LINQ over array methods.
const people = [ { name: "ada", team: "core" }, { name: "bob", team: "web" }, { name: "cyd", team: "core" }, ]; const byTeam = {}; for (const person of people) { (byTeam[person.team] ??= []).push(person.name); } for (const team of Object.keys(byTeam).sort()) { console.log(team, byTeam[team].sort().join(",")); }
var people = new[] { new Person("ada", "core"), new Person("bob", "web"), new Person("cyd", "core"), }; foreach (var group in people.GroupBy(person => person.Team).OrderBy(group => group.Key)) { Console.WriteLine($"{group.Key} {string.Join(",", group.Select(p => p.Name).OrderBy(name => name))}"); } record Person(string Name, string Team);
The rest of the vocabulary: OrderBy and ThenBy for stable multi-key sorting (JavaScript's sort takes one comparator and you write the tie-break by hand), Distinct, Zip, ToDictionary, and the ones JavaScript does share under other names — SelectMany is flatMap, and Any and All are some and every. There is also a query syntax — from person in people group person by person.Team — which reads like SQL and which most modern code does not use.
Classes & Interfaces
Classes, with real access control
The shapes line up closely — constructor, methods, a getter — with private as a keyword rather than a # sigil, and public required on anything callers should see, since members are private by default.
class Counter { #value; // genuinely private, since 2022 constructor(start = 0) { this.#value = start; } increment(by = 1) { this.#value += by; return this.#value; } get value() { return this.#value; } } const counter = new Counter(10); counter.increment(); console.log(counter.increment(5), counter.value);
var counter = new Counter(10); counter.Increment(); Console.WriteLine($"{counter.Increment(5)} {counter.Value}"); class Counter { private int value; public Counter(int start = 0) => value = start; public int Increment(int by = 1) { value += by; return value; } public int Value => value; // an expression-bodied property }
Two things a JavaScript developer gains. There is no this rebinding problem: in a class, this is always the instance, so no bind, no self = this, and no arrow-function-to-preserve-this. And properties are first-class — public int Value => value; is a getter, { get; set; } is a field with an accessor, and { get; init; } allows setting only during construction. The class sits at the bottom of the file because a file of top-level statements must put its type declarations after the statements (error CS8803); the statements above can still use it.
Duck typing becomes a declared interface
C# needs the relationship written down: a type must say : IShape to be accepted where an IShape is wanted, even if it already has exactly the right method.
// Nothing is declared. If it has the method, it works. function report(shape) { console.log("area:", shape.area()); } report({ area: () => 4 }); // an object literal is enough report({ area() { return 9; } });
Report(new Square()); Report(new Circle()); static void Report(IShape shape) => Console.WriteLine($"area: {shape.Area()}"); interface IShape { double Area(); } class Square : IShape { public double Area() => 4; } class Circle : IShape { public double Area() => 9; }
This is nominal typing, and it is the opposite of both JavaScript's duck typing and TypeScript's structural typing — a TypeScript developer will find this the sharpest change, since a matching shape is no longer enough. What you get is that the compiler can tell you every implementation of an interface, refactoring tools can rename across all of them, and a type cannot accidentally satisfy an interface it knows nothing about. The I prefix is a .NET convention you will see everywhere.
Generics are real at run time
A type parameter in angle brackets, and — unlike Java, where a JavaScript developer may have heard the term — it survives to run time, so List<int> genuinely stores integers rather than boxed objects.
// JavaScript has no generics because it has no types to parameterize. // A function is generic by not caring: function describe(values) { return values.length > 0 ? `${values[0]} (a ${typeof values[0]})` : "empty"; } console.log(describe([1, 2])); console.log(describe(["a"])); console.log(describe([]));
Console.WriteLine(Describe(new List<int> { 1, 2 })); Console.WriteLine(Describe(new List<string> { "a" })); Console.WriteLine(Describe(new List<int>())); // The type parameter is REIFIED: it exists at run time, so a // List<int> stores ints without boxing, and typeof(Element) works. static string Describe<Element>(List<Element> values) => values.Count > 0 ? $"{values[0]} (a {typeof(Element).Name})" : "empty";
Generics are the mechanism behind everything a JavaScript developer meets in .NET: List<T>, Dictionary<K, V>, Task<T>, IEnumerable<T>. Constraints are written with where — where T : IComparable<T>, where T : class, where T : new() — and are what let the compiler check the body once rather than at each use. TypeScript generics look similar and are erased; C# generics are not.
null, And What Was Done About It
One null, and the compiler tracks it
Two things improve at once. There is no undefined — one absent value instead of two, so no === undefined versus === null question — and the compiler tracks nullability through the program.
let name = null; let missing; // undefined — a SECOND absent value console.log(name ?? "default"); console.log(missing?.length); // undefined, no error // Nothing warns you here. It throws at run time. try { console.log(name.length); } catch (error) { console.log("caught:", error.constructor.name); }
// There is no undefined. One null, and with nullable reference types // enabled the compiler tracks which references may be it. string? name = null; string definitelyPresent = "widget"; Console.WriteLine(name ?? "default"); Console.WriteLine(name?.Length.ToString() ?? "no length"); // name.Length would be a WARNING at compile time (an error with // warnings-as-errors, which most projects turn on). Console.WriteLine(definitelyPresent.Length);
string? means "may be null" and string means "will not be", and the compiler warns when you dereference the first without checking or assign null to the second. It is a warning rather than an error because the feature was added to a language with twenty years of existing code, and most new projects turn warnings into errors. ??, ?. and ??= are all present and mean what they do in JavaScript.
🚨 Truthiness is gone
An if requires a bool and no built-in type converts to one implicitly (only a type whose author defines such a conversion does), so the entire truthiness table — the reason [] == false is true and "0" is truthy — simply does not apply.
const values = [0, "", null, undefined, NaN, [], {}, "0"]; for (const value of values) { console.log(JSON.stringify(value), "→", Boolean(value)); } // Empty array and empty object are TRUTHY; "0" is truthy; 0 is not.
// An if takes a bool. No built-in type converts to one, so none of // the JavaScript truthiness table exists to be memorized. int count = 0; string text = ""; List<int> empty = new(); // if (count) { } ← will not compile: cannot convert int to bool if (count == 0) Console.WriteLine("count is zero"); if (text.Length == 0) Console.WriteLine("text is empty"); if (empty.Count == 0) Console.WriteLine("list is empty"); if (text is "") Console.WriteLine("pattern match works too");
Every check becomes explicit, which is more typing and one fewer thing to get wrong: if (value != null), if (text.Length > 0), if (list.Any()). The convenience shorthands JavaScript developers miss most are string.IsNullOrEmpty(text) and string.IsNullOrWhiteSpace(text), which are exactly the if (!text) that people write in JavaScript and which cover the common case.
async/await Means Something Else
🚨 The same keyword, a different mechanism
The syntax is nearly identical and the machinery underneath is not. Task is Promise, Task.WhenAll is Promise.all, Task.Delay is a setTimeout wrapped in a promise.
// ONE thread and one event loop. await yields to it, and nothing // else in your program runs while synchronous code is running. async function work(name, milliseconds) { await new Promise(resolve => setTimeout(resolve, milliseconds)); return `${name} done`; } async function main() { const results = await Promise.all([work("first", 20), work("second", 10)]); for (const result of results) console.log(result); } main();
// A thread POOL. await frees the current thread to do other work, // and the continuation may resume on a DIFFERENT thread. async Task<string> Work(string name, int milliseconds) { await Task.Delay(milliseconds); return $"{name} done"; } var results = await Task.WhenAll(Work("first", 20), Work("second", 10)); foreach (var result in results) Console.WriteLine(result);
The difference that matters: JavaScript has one thread, so await is about yielding to the event loop, and a long synchronous loop freezes everything. C# has a thread pool, so await is about not occupying a thread while waiting — and the code after an await may run on a different thread than the code before it. That is why shared mutable state needs locking in C# and never does in JavaScript, and it is the single biggest conceptual adjustment on this page.
Real parallelism, for free
This is the capability with no JavaScript equivalent short of worker threads and message passing. .AsParallel() turns a LINQ query into a parallel one, and the work is spread across cores with shared memory.
// CPU-bound work blocks the one thread. Promise.all does not help: // these run one after another, and the page or server is frozen // meanwhile. Real parallelism needs worker_threads and message // passing, with no shared objects. function slowSum(limit) { let total = 0; for (let index = 0; index < limit; index++) total += index; return total; } console.log(slowSum(1_000_00) + slowSum(1_000_00));
// Parallel.For and PLINQ spread CPU-bound work over real threads, // with shared memory and no message passing. long SlowSum(int limit) { long total = 0; for (int index = 0; index < limit; index++) total += index; return total; } var results = new[] { 100_000, 100_000 } .AsParallel() .Select(SlowSum) .Sum(); Console.WriteLine(results);
The rule of thumb that keeps this straight: async/await is for waiting (input and output — a database, a file, a request), and Parallel.For, Task.Run and PLINQ are for computing. Using async for CPU work does not make it parallel, and using threads for waiting wastes them. The obligation that arrives with real threads is real too: two threads writing the same variable is a data race, so shared mutable state needs lock, Interlocked or a concurrent collection.
Cancellation is built in
CancellationToken is AbortSignal, with the difference that it is a framework-wide convention rather than something each API opted into — a .NET method that waits on I/O or a timer usually takes a token.
// AbortController, and only where an API chose to support it: const controller = new AbortController(); async function work(signal) { for (let index = 0; index < 3; index++) { if (signal.aborted) return "cancelled"; await new Promise(resolve => setTimeout(resolve, 1)); } return "finished"; } async function main() { console.log(await work(controller.signal)); } main();
using System.Threading; // CancellationToken, and it is a convention the framework follows: // long-running async APIs in .NET take one. using var source = new CancellationTokenSource(); async Task<string> Work(CancellationToken token) { for (int index = 0; index < 3; index++) { if (token.IsCancellationRequested) return "cancelled"; await Task.Delay(1, token); } return "finished"; } Console.WriteLine(await Work(source.Token));
Two conveniences worth knowing: CancellationTokenSource can be constructed with a timeout, so "cancel after five seconds" is one line, and tokens compose with CreateLinkedTokenSource, so a request-scoped token can cancel everything below it. Passing the token down through every layer is the discipline it asks for, and it is why you see CancellationToken token = default as the last parameter of so many .NET methods.
Errors
Exceptions have types
Multiple catch blocks select by exception type, and the when filter adds a condition — so telling failures apart does not mean inspecting a message string.
function parse(text) { const value = Number(text); if (Number.isNaN(value)) throw new Error(`not a number: ${text}`); return value; } try { console.log(parse("42")); console.log(parse("oops")); } catch (error) { // One catch for everything. Telling errors apart means inspecting // the message, or instanceof against classes you defined yourself. console.log("caught:", error.message); } finally { console.log("finally runs"); }
try { Console.WriteLine(Parse("42")); Console.WriteLine(Parse("oops")); } catch (FormatException error) when (error.Message.Contains("number")) { Console.WriteLine($"caught: {error.Message}"); } catch (Exception error) { Console.WriteLine($"something else: {error.Message}"); } finally { Console.WriteLine("finally runs"); } static int Parse(string text) => int.TryParse(text, out var value) ? value : throw new FormatException($"not a number: {text}");
The .NET exception hierarchy is worth a little study because it is what you catch: ArgumentException, InvalidOperationException, FormatException, KeyNotFoundException, NullReferenceException. Two habits to bring: never catch bare Exception except at a top-level boundary, and use the Try pattern (int.TryParse) for failures you expect, since throwing is comparatively expensive. A C# exception carries a full stack trace, as a JavaScript Error does.
using is the cleanup you had to remember
using is try/finally with the cleanup moved into the type: anything implementing IDisposable is disposed when the enclosing scope ends, including when an exception unwinds.
// try/finally is the only tool, and every caller must remember it. function work() { const held = { name: "resource" }; console.log("opened", held.name); try { console.log("working"); } finally { console.log("closed", held.name); } } work(); console.log("after work()");
Work(); Console.WriteLine("after work()"); static void Work() { using var held = new Held("resource"); // disposed at the end of scope Console.WriteLine("working"); } class Held : IDisposable { private readonly string name; public Held(string name) { this.name = name; Console.WriteLine($"opened {name}"); } public void Dispose() => Console.WriteLine($"closed {name}"); }
JavaScript has been adding the same idea — the using declaration and Symbol.dispose — and .NET has had it since the beginning, which is why every file, connection, HTTP client and stream in the framework implements it. The rule to absorb: if a type implements IDisposable, dispose it — using is how — with a few documented exceptions such as Task. await using is the asynchronous form.
Namespaces, Assemblies & NuGet
Modules become namespaces and assemblies
The two ideas JavaScript merges are separate here: a namespace organizes names and has nothing to do with files, while an assembly is the unit that gets built and loaded.
// One file is one module, and what it exports is what other files // can import. The path in the import IS the file location. // // import { formatReport } from "./reporting.js"; // export function formatReport(rows) { ... } // // Node then has its own resolution rules, package.json "exports", // CommonJS versus ESM, and the whole node_modules tree. console.log("one file, one module");
// A namespace is a NAME, not a file. Any number of files may // contribute to one, and a using directive says which names are in // scope — it does not load anything. // // namespace Acme.Reporting; // public static class Report { public static string Format(...) } // // using Acme.Reporting; // no path, no file, no resolution // // What actually gets loaded is the ASSEMBLY — one .dll built from a // project — and the project file lists which assemblies it references. Console.WriteLine("namespaces name things; assemblies load them");
So using System.Linq; does not load anything — it makes those names visible, and the code is already in an assembly the project references. That removes a set of problems a Node developer knows well: no resolution algorithm, no two copies of the same package at different versions in one tree, no CommonJS-versus-ESM split, and no node_modules. Loading an implementation by path is still possible — Assembly.LoadFrom and AssemblyLoadContext are how plugin systems work — but it is rare, and dependency injection is the everyday way to swap one.
npm becomes NuGet
The mechanics are familiar and the ecosystem culture is not: NuGet resolves each package to one version for the whole project, rather than allowing several copies at different versions the way node_modules does.
// npm install newtonsoft-ish-thing // // package.json lists dependencies; node_modules holds a tree of // them, possibly with several versions of the same package; the // lock file pins what was actually installed. console.log("many small packages, deep trees");
// dotnet add package Newtonsoft.Json // // <PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> // // One flat set of resolved versions per project — NuGet unifies to a // single version of each package, so there is no tree of duplicates. Console.WriteLine("fewer, larger packages; one version each");
That trade is worth understanding rather than judging. Flat resolution means no duplicate copies and a much smaller dependency tree, and it means a version conflict between two of your dependencies is a problem you must resolve rather than one the tool papers over. The other cultural difference is that the .NET base class library is very large — JSON, HTTP, cryptography, dates and regular expressions are all in the box, and dependency injection and logging come with the ASP.NET Core and generic-host frameworks — so the median project has perhaps a dozen dependencies rather than several hundred.
Tooling
One toolchain, in the box
This is the ordinary experience of moving in this direction and it is the thing JavaScript developers most often report as a relief: one tool, versioned with the runtime, with no choices to make before writing any code.
// The JavaScript answer is assembled from parts, and the parts // change every couple of years: // // node / bun / deno the runtime // npm / pnpm / yarn the package manager // esbuild / vite / webpack the bundler // jest / vitest / node:test the test runner // eslint + prettier lint and format // typescript types console.log("assembled from parts, and the parts change");
// dotnet is all of it: // // dotnet new console scaffold // dotnet build compile // dotnet run run // dotnet test test (xunit, nunit or mstest) // dotnet format format // dotnet publish produce a deployable artifact // dotnet watch rebuild and rerun on save // // Types, analyzers and the debugger come with the compiler. Console.WriteLine("one toolchain, versioned with the runtime");
What you give up is the ecosystem's pace and its flexibility — there is no swapping the bundler for a faster one, because there is no bundler. What you get is that a five-year-old .NET project still builds, the debugger works without configuration, and every project is laid out the same way. Two tools worth knowing early: dotnet watch is nodemon, and the analyzers built into the compiler cover much of what ESLint does, configured in .editorconfig.

Thank you — anything else?