PONYλM2Modula-2

C.CodeCompared.To/JavaScript

An interactive executable cheatsheet comparing C and JavaScript

C17 (GCC) JavaScript (ES2025)
Hello, World & Output
Hello, World
There is no main, because there is nothing to link. The file is the program, statements run top to bottom as they are read, and the whole compile-link-run cycle is one step that happens as the page loads.
#include <stdio.h> int main(void) { printf("Hello, World!\n"); return 0; }
console.log("Hello, World!");
console.log appends the newline, converts whatever you give it to text, and separates several arguments with spaces — so it is closer to puts than to printf. There is no exit status to return: a browser script simply finishes, and Node uses process.exit when a status matters.
The Error Arrives While Running, Not Before
A C mistake of this shape is caught by the compiler before anything runs. JavaScript parses the whole file for syntax, then finds everything else as it goes — so an error can sit undiscovered in a branch that has not been taken yet.
#include <stdio.h> int main(void) { int count = 3; /* Calling an int would not compile: count(); error: called object 'count' is not a function The program never runs at all. */ printf("count is %d\n", count); return 0; }
let count = 3; console.log("count is " + count); // The same mistake is found only when this line is reached: try { count(); } catch (error) { console.log(error.constructor.name + ": " + error.message); } // And a mistake inside a branch nothing takes is never found at all: if (false) { undefinedFunction(); } console.log("still running");
This is the single largest working difference. A C build is a whole-program check: every call is against a declaration, every type is known, and a mistake stops the build. JavaScript checks syntax up front and nothing else, so the equivalent safety net has to come from tests, from linting, or from TypeScript — which is exactly why TypeScript exists.
Interpolation Instead of a Format String
A template literal puts the expressions where the conversions used to be, and there is nothing to get wrong about widths because there are no widths — every value is converted by the same rules.
#include <stdio.h> int main(void) { const char *name = "Ada"; int count = 3; double average = 2.5; printf("%s has %d items averaging %.2f\n", name, count, average); return 0; }
const name = "Ada"; const count = 3; const average = 2.5; console.log(`${name} has ${count} items averaging ${average.toFixed(2)}`);
The precision moved from the format string onto the value: %.2f became toFixed(2), which returns a string. Because there is no format string there is no way to pass the wrong number of arguments or the wrong conversion — the two most common printf mistakes cannot be written. What you lose is field widths, which come back as padStart and padEnd.
No Declared Types, No Compile Step
A Variable Has No Declared Type
let and const introduce a name and nothing else. The type belongs to the value currently stored there, and you ask about it at run time with typeof.
#include <stdio.h> int main(void) { int count = 42; double ratio = 2.5; const char *label = "answer"; /* The type is fixed at the declaration and known to the compiler. count = "text"; would not compile. */ printf("%d %.1f %s\n", count, ratio, label); return 0; }
let count = 42; let ratio = 2.5; let label = "answer"; console.log(count, ratio, label); console.log(typeof count, typeof ratio, typeof label);
Notice that count and ratio both report number — there is no separate integer type to distinguish them. typeof answers with one of a short fixed list (number, string, boolean, bigint, symbol, undefined, function, object), and everything structured, including arrays and null, answers object.
The Same Name Can Hold a Different Kind of Value
In C a variable is storage of a fixed size and interpretation. In JavaScript a variable is a slot that holds a reference to whatever value is currently in it, so assigning a string where a number was is not a conversion — it is a different value in the same slot.
#include <stdio.h> int main(void) { int value = 42; printf("%d\n", value); /* value = "text"; does not compile. The nearest legal move is a different variable, or a union, or a void * plus a tag. */ const char *text = "text"; printf("%s\n", text); return 0; }
let value = 42; console.log(value, typeof value); value = "text"; console.log(value, typeof value); value = [1, 2, 3]; console.log(value.length, typeof value, Array.isArray(value));
This is what makes the language flexible and what makes large programs in it hard: nothing records what a name is supposed to hold, so the only way to know is to read every assignment to it. The discipline that replaces the compiler is convention — one type per variable, enforced by review, by a linter, or by moving to TypeScript, where these declarations come back.
const Freezes the Name, Not the Value
C’s const is a promise about the object: writing through a const int * is a compile error. JavaScript’s const is a promise about the binding: the name cannot be pointed at something else, but the thing it points at is as mutable as ever.
#include <stdio.h> int main(void) { int numbers[3] = { 1, 2, 3 }; const int *readonly = numbers; /* readonly[0] = 99; does not compile — the pointee is const. */ printf("%d %d %d\n", readonly[0], readonly[1], readonly[2]); int *writable = numbers; writable[0] = 99; printf("%d\n", numbers[0]); return 0; }
const numbers = [1, 2, 3]; numbers[0] = 99; // allowed — the array is not const numbers.push(4); // also allowed console.log(numbers.join(" ")); try { numbers = [5, 6]; // NOT allowed — the binding is const } catch (error) { console.log(error.constructor.name + ": cannot rebind"); } const frozen = Object.freeze({ value: 1 }); frozen.value = 2; // silently ignored (throws in strict mode) console.log("frozen.value is still " + frozen.value);
If you want C’s meaning you need Object.freeze, and it is shallow — freezing an object does not freeze the objects inside it. In practice const is used almost everywhere in modern JavaScript simply because a name that never gets repointed is easier to read; the immutability you may be expecting from the keyword is not part of the deal.
Two Kinds of Nothing
C has one NULL and reaching an uninitialized variable is undefined behavior. JavaScript has two distinct absent values: undefined means nobody ever put anything here, and null means somebody deliberately put "nothing" here.
#include <stdio.h> struct Person { const char *name; const char *nickname; }; int main(void) { struct Person person = { "Ada", NULL }; printf("name: %s\n", person.name); /* Only one way to say "absent", and printing it directly is undefined behavior — the check is mandatory. */ printf("nickname: %s\n", person.nickname ? person.nickname : "(none)"); return 0; }
const person = { name: "Ada", nickname: null }; console.log("name: " + person.name); console.log("nickname: " + (person.nickname ?? "(none)")); // A field nobody set at all reads as undefined, not as an error: console.log("age: " + person.age); console.log(typeof person.age, typeof person.nickname); console.log("null == undefined:", null == undefined); console.log("null === undefined:", null === undefined);
Reading a field that does not exist gives undefined rather than failing, which is the behavior that turns a typo into a silent wrong answer instead of a compile error. The two absent values compare equal under == and unequal under ===, and typeof null famously answers "object" — a bug from 1995 that can no longer be fixed without breaking the web.
There Is No sizeof, Because There Is No Layout
Every question sizeof answers is about storage you control. JavaScript does not let you see storage at all — a number is a number, an object is a reference, and the engine may represent either several different ways depending on what you have done with it.
#include <stdio.h> struct Point { int x; int y; }; int main(void) { printf("int %zu\n", sizeof(int)); printf("double %zu\n", sizeof(double)); printf("Point %zu\n", sizeof(struct Point)); printf("ptr %zu\n", sizeof(void *)); return 0; }
const point = { x: 1, y: 2 }; console.log("typeof point.x " + typeof point.x); console.log("typeof point " + typeof point); console.log("field count " + Object.keys(point).length); // The one place sizes are real again is a typed array: const buffer = new ArrayBuffer(8); console.log("ArrayBuffer bytes " + buffer.byteLength); console.log("Int32Array element " + Int32Array.BYTES_PER_ELEMENT);
An engine typically stores a small integer unboxed and a large one as a heap double, and it will change representation underneath you when an array stops being uniform. That is why there is nothing meaningful to report for sizeof, and why the typed arrays in the last section of this page are the only place where a byte count is a real answer.
One Number Type, and It Is a double
Every Number Is a double
There is no int. 1, 1.0 and 1e0 are the same value, stored as an IEEE 754 double — which means the floating-point surprises you know from C apply to what look like integer expressions.
#include <stdio.h> int main(void) { int whole = 7; double fraction = 0.1 + 0.2; printf("7 / 2 = %d\n", whole / 2); /* integer division */ printf("0.1 + 0.2 = %.17f\n", fraction); printf("equal to 0.3? %d\n", fraction == 0.3); return 0; }
console.log("7 / 2 = " + (7 / 2)); // no integer division console.log("0.1 + 0.2 = " + (0.1 + 0.2).toFixed(17)); console.log("equal to 0.3? " + (0.1 + 0.2 === 0.3)); console.log("Number.isInteger(7/2) " + Number.isInteger(7 / 2)); console.log("7 / 2 truncated " + Math.trunc(7 / 2));
The two columns agree exactly about 0.1 + 0.2, because both are doing the same IEEE 754 arithmetic. Where they part company is 7 / 2: C picks integer division from the operand types and gives 3, while JavaScript has no integer type to pick, so it gives 3.5. Math.trunc, Math.floor and the | 0 idiom are how you get C’s answer back.
Integers Are Exact Only to 2⁵³
A double has 53 bits of significand, so every integer up to 9,007,199,254,740,991 is exact and beyond that the representable values start skipping. A C int64_t counts to 9.2 × 10¹⁸ exactly; JavaScript’s number does not.
#include <stdio.h> #include <stdint.h> #include <inttypes.h> int main(void) { int64_t big = 9007199254740993; /* exact in 64 bits */ printf("%" PRId64 "\n", big); printf("%" PRId64 "\n", big + 1); double as_double = (double)big; printf("as double: %.0f\n", as_double); return 0; }
console.log(Number.MAX_SAFE_INTEGER); // 2**53 - 1 console.log(9007199254740993); // one past it console.log(9007199254740993 === 9007199254740992); console.log(Number.isSafeInteger(9007199254740993));
The last comparison is true, which is the whole problem: two different integers landed on the same double. This is why a JSON payload carrying a 64-bit database identifier must send it as a string — parsing it as a number silently corrupts it. The fix inside the language is BigInt, two rows down.
The Bitwise Operators Secretly Truncate to 32 Bits
The operators are spelled the same and mean the same thing, with one enormous caveat: each operand is converted to a signed 32-bit integer first and the result is converted back to a double. Bit work on anything wider silently loses the high bits.
#include <stdio.h> #include <stdint.h> #include <inttypes.h> int main(void) { uint32_t flags = 0b1010; printf("%u\n", flags & 0b0110); printf("%u\n", flags | 0b0101); printf("%u\n", flags << 2); /* 64-bit shifts stay 64-bit. */ uint64_t wide = 1ULL << 40; printf("%" PRIu64 "\n", wide); return 0; }
const flags = 0b1010; console.log(flags & 0b0110); console.log(flags | 0b0101); console.log(flags << 2); // A shift past 32 bits wraps the shift count, and the value truncates: console.log("1 << 40 gives " + (1 << 40)); console.log("2**40 | 0 gives " + (2 ** 40 | 0)); console.log(">>> is the unsigned shift: " + (-1 >>> 0));
1 << 40 prints 256, not a trillion — the shift count is taken modulo 32, so it shifted by 8. >>> is the one operator C has no spelling for: it shifts in zeroes regardless of sign, which is how you read a 32-bit value as unsigned. For genuine 64-bit bit work, use BigInt, whose operators do not truncate.
Remainder, Truncation, and the Sign
C99 made % truncate toward zero, so -17 % 5 is -2. JavaScript agrees — which is worth knowing precisely because so many other languages do not, and because the operand types no longer decide anything.
#include <stdio.h> #include <math.h> int main(void) { printf("-17 / 5 = %d\n", -17 / 5); printf("-17 %% 5 = %d\n", -17 % 5); printf("floor = %.0f\n", floor(-17.0 / 5.0)); printf("round(2.5) = %.0f\n", round(2.5)); return 0; }
console.log("-17 / 5 = " + Math.trunc(-17 / 5)); console.log("-17 % 5 = " + (-17 % 5)); console.log("floor = " + Math.floor(-17 / 5)); console.log("round(2.5) = " + Math.round(2.5));
One row where the two disagree is rounding a tie: C’s round goes away from zero, so round(-2.5) is −3, while Math.round always goes toward positive infinity and gives −2. The other thing to carry over: % works on non-integers here (7.5 % 2 is 1.5), because there are no integers for it to insist on.
BigInt: Arbitrary Precision in the Language
When 64 bits are not enough, C reaches for GMP. JavaScript has a second numeric type built in — integers of unbounded size, written with a trailing n.
#include <stdio.h> #include <stdint.h> #include <inttypes.h> int main(void) { /* 20! fits in 64 bits; 21! does not, and this overflows silently. */ uint64_t factorial = 1; for (uint64_t index = 1; index <= 21; index++) { factorial *= index; } printf("21! as uint64 = %" PRIu64 "\n", factorial); printf("(the true value is 51090942171709440000)\n"); return 0; }
let factorial = 1n; for (let index = 1n; index <= 21n; index++) { factorial *= index; } console.log("21! as BigInt = " + factorial); console.log((2n ** 100n).toString()); console.log("typeof: " + typeof factorial); // The two numeric types do not mix without an explicit conversion: try { console.log(1n + 1); } catch (error) { console.log(error.constructor.name + ": cannot mix BigInt and Number"); }
The columns disagree on purpose: 21! overflows an unsigned 64-bit integer and the C column prints the wrapped value, while the BigInt grows to fit. The deliberate friction is that BigInt and Number will not add — mixing them throws rather than silently rounding, which is the mistake the type exists to prevent. Bitwise operators on BigInt do not truncate to 32 bits either, so this is also the way to do genuine 64-bit bit manipulation.
Strings Are Objects, Not char *
A String Is a Value With a Stored Length
A C string is a pointer plus a convention about a zero byte. A JavaScript string is a primitive value that knows its own length, cannot be written into, and carries methods.
#include <stdio.h> #include <string.h> int main(void) { const char *greeting = "hello"; printf("length %zu\n", strlen(greeting)); printf("upper: "); for (size_t index = 0; index < strlen(greeting); index++) { putchar(greeting[index] - 'a' + 'A'); } printf("\n"); printf("contains ell: %d\n", strstr(greeting, "ell") != NULL); return 0; }
const greeting = "hello"; console.log("length " + greeting.length); console.log("upper: " + greeting.toUpperCase()); console.log("contains ell: " + greeting.includes("ell"));
strlen walks to the terminator every time it is called, which is why the C loop above is accidentally quadratic; .length is a stored property. The other habit to unlearn: there is no buffer to size and no terminator to leave room for, so the entire family of off-by-one string bugs simply has nowhere to live.
You Cannot Write Into a String
Every operation that appears to change a string returns a new one. Subscripting works for reading and is silently ignored for writing, which is the one place this bites: the assignment does not fail, it just does nothing.
#include <stdio.h> int main(void) { char editable[] = "hello"; editable[0] = 'H'; /* we own the array */ printf("%s\n", editable); /* A string literal is not writable: char *literal = "hello"; literal[0] = 'H'; <- undefined behavior */ return 0; }
let greeting = "hello"; greeting[0] = "H"; // no error, and no effect console.log(greeting); greeting = "H" + greeting.slice(1); console.log(greeting); console.log("replace: " + "hello".replace("h", "H")); console.log("original: " + "hello");
The silent no-op is a genuine trap — in strict mode (which every module and class body is) it throws instead, which is better. Because strings never change, two variables holding the same string can share one representation with no aliasing hazard, and the engine is free to store a concatenation as a rope until somebody actually reads it.
length Counts UTF-16 Code Units, Not Characters
A C char is a byte and a UTF-8 string needs decoding to be read as characters. A JavaScript string is UTF-16, so most characters are one unit — and anything outside the Basic Multilingual Plane, emoji included, is two.
#include <stdio.h> #include <string.h> int main(void) { const char *plain = "cafe"; const char *accented = "caf\u00e9"; /* UTF-8: 5 bytes */ const char *emoji = "\U0001F600"; /* UTF-8: 4 bytes */ printf("plain bytes %zu\n", strlen(plain)); printf("accented bytes %zu\n", strlen(accented)); printf("emoji bytes %zu\n", strlen(emoji)); return 0; }
const plain = "cafe"; const accented = "caf\u00e9"; const emoji = "\u{1F600}"; console.log("plain length " + plain.length); console.log("accented length " + accented.length); console.log("emoji length " + emoji.length); console.log("emoji characters " + [...emoji].length); console.log("emoji UTF-8 bytes " + new TextEncoder().encode(emoji).length);
The emoji is one character, two UTF-16 code units, and four UTF-8 bytes, and each column reports the number its own representation makes natural. Spreading with [...string] iterates by code point rather than code unit, which is the fix for slicing a string in half through an emoji; Intl.Segmenter goes further and iterates by what a reader would call a character.
Concatenation Without a Buffer
The C version has to decide how big the result can be before it can start. The JavaScript version does not have a buffer at all, so building text in a loop is just += — and the engine, not you, worries about the copying.
#include <stdio.h> #include <string.h> int main(void) { char buffer[64]; buffer[0] = '\0'; for (int index = 1; index <= 5; index++) { char piece[8]; snprintf(piece, sizeof piece, "%d,", index); strncat(buffer, piece, sizeof buffer - strlen(buffer) - 1); } printf("%s\n", buffer); return 0; }
let text = ""; for (let index = 1; index <= 5; index++) { text += index + ","; } console.log(text); // The idiomatic version builds the pieces and joins once: console.log([1, 2, 3, 4, 5].map(value => value + ",").join(""));
The += loop is not the performance trap it looks like: every major engine stores the intermediate result as a rope and flattens it only when the string is actually read. It is still worth reaching for join, because it says what you mean and does not depend on that optimization. What has genuinely gone is sizeof buffer - strlen(buffer) - 1, which is the arithmetic behind a large share of C buffer overflows.
split Instead of strtok
strtok writes terminators into your buffer, keeps its place in a static variable, and therefore cannot be nested, threaded, or pointed at a literal. split returns a new array and touches nothing.
#include <stdio.h> #include <string.h> int main(void) { char text[] = "red,green,blue"; /* must be writable */ char *piece = strtok(text, ","); while (piece != NULL) { printf("[%s]", piece); piece = strtok(NULL, ","); } printf("\n"); return 0; }
const text = "red,green,blue"; for (const piece of text.split(",")) { console.log("[" + piece + "]"); } console.log(text.split(",").join(" | ")); console.log("original intact: " + text);
A split with a regular expression separator covers what strtok’s multi-character delimiter set was for, and split("") gives you an array of single characters. The cost is an allocation per piece, which is the price of not modifying the input — and given that the input is a string, and strings cannot be modified, there was never an alternative.
Arrays That Grow
Arrays Grow By Themselves
The capacity-tracking, doubling, realloc-and-check loop is the same code in every C program. A JavaScript array grows when you push to it, and shrinks when you pop.
#include <stdio.h> #include <stdlib.h> int main(void) { size_t capacity = 2, count = 0; int *numbers = malloc(capacity * sizeof *numbers); if (numbers == NULL) return 1; for (int value = 1; value <= 5; value++) { if (count == capacity) { capacity *= 2; int *grown = realloc(numbers, capacity * sizeof *numbers); if (grown == NULL) { free(numbers); return 1; } numbers = grown; } numbers[count++] = value * value; } for (size_t index = 0; index < count; index++) { printf("%d ", numbers[index]); } printf("\n"); free(numbers); return 0; }
const numbers = []; for (let value = 1; value <= 5; value++) { numbers.push(value * value); } console.log(numbers.join(" ")); console.log("length " + numbers.length); numbers.pop(); console.log("after pop, length " + numbers.length);
Under the hood an engine really does keep a contiguous backing store and grow it geometrically, so the cost model matches your instincts — as long as the array stays uniform. Store a string in an array of numbers and the engine may switch to a boxed representation, which costs an indirection per element from then on. That is the JavaScript equivalent of a struct falling out of a register.
Out of Range Returns undefined, Not Garbage
Reading past the end of a C array is undefined behavior. Reading past the end of a JavaScript array yields undefined — no error, no crash, and the arithmetic that follows quietly produces NaN.
#include <stdio.h> int main(void) { int numbers[3] = { 10, 20, 30 }; int wanted = 5; /* numbers[5] is undefined behavior, so the check is mandatory. */ if (wanted >= 0 && wanted < 3) { printf("%d\n", numbers[wanted]); } else { printf("index %d is out of range\n", wanted); } return 0; }
const numbers = [10, 20, 30]; console.log("numbers[5] is " + numbers[5]); console.log("arithmetic gives " + (numbers[5] + 1)); console.log("at(-1) is " + numbers.at(-1)); // Writing past the end extends the array with a hole: numbers[5] = 60; console.log("length is now " + numbers.length); console.log("the hole reads as " + numbers[4]);
The safety here is real — nothing is corrupted — but the diagnosis is worse than C’s: instead of a crash near the mistake you get NaN propagating through a calculation until it surfaces somewhere unrelated. at(-1) is the modern way to reach the last element, and assigning past the end really does make a sparse array, whose holes read as undefined and are skipped by forEach but not by a for loop.
map, filter and reduce Instead of Three Loops
Filtering, transforming and accumulating are three hand-written loops in C, each with its own index and accumulator. Here they are named operations, and the loop is inside them.
#include <stdio.h> int main(void) { int numbers[] = { 3, 1, 4, 1, 5, 9, 2, 6 }; size_t count = sizeof numbers / sizeof numbers[0]; int total = 0, matches = 0; for (size_t index = 0; index < count; index++) { if (numbers[index] % 2 == 0) { total += numbers[index] * numbers[index]; matches++; } } printf("%d even values, squares total %d\n", matches, total); return 0; }
const numbers = [3, 1, 4, 1, 5, 9, 2, 6]; const evenSquares = numbers.filter(value => value % 2 === 0) .map(value => value * value); const total = evenSquares.reduce((sum, value) => sum + value, 0); console.log(evenSquares.length + " even values, squares total " + total); console.log("largest three: " + [...numbers].sort((left, right) => right - left).slice(0, 3).join(" "));
Unlike the equivalents in some languages these are eager: each one walks the whole array and builds a new one, so a three-stage chain over a million elements does three passes and two allocations. That is usually fine and occasionally not, which is when you write the single loop back out by hand. Note also [...numbers] before sortsort reorders in place and would otherwise modify the original.
sort Compares as Text Unless You Say Otherwise
C’s qsort makes you supply the comparison, so there is no default to be surprised by. JavaScript’s sort has a default, and it converts every element to a string first — which sorts numbers into an order nobody wants.
#include <stdio.h> #include <stdlib.h> static int compare_integers(const void *left, const void *right) { int first = *(const int *)left; int second = *(const int *)right; return (first > second) - (first < second); } int main(void) { int numbers[] = { 10, 9, 1, 100, 20 }; size_t count = sizeof numbers / sizeof numbers[0]; qsort(numbers, count, sizeof numbers[0], compare_integers); for (size_t index = 0; index < count; index++) { printf("%d ", numbers[index]); } printf("\n"); return 0; }
const numbers = [10, 9, 1, 100, 20]; console.log("default: " + [...numbers].sort().join(" ")); console.log("comparator: " + [...numbers].sort((left, right) => left - right).join(" "));
The default line prints 1 10 100 20 9 — correct string order, useless numeric order — and it is one of the most reliably surprising defaults in the language. The comparator has the same contract as qsort’s: negative, zero, or positive. Subtraction is the usual shorthand and is safe here, but it overflows for very large values in the same way it would in C, so (a > b) - (a < b) is still the careful spelling.
An Object Is a Hash Table
The Struct and the Hash Table Are the Same Thing
C has a struct, whose fields are fixed at compile time and cost nothing to reach, and a hash table you write yourself. JavaScript has one construct that is both: an object is a set of string keys mapped to values, with a literal syntax and dotted access.
#include <stdio.h> #include <string.h> struct Person { const char *name; int age; }; int main(void) { struct Person person = { "Ada", 36 }; printf("%s is %d\n", person.name, person.age); /* Looking a field up by a name computed at run time is not possible — the field set is fixed at compile time. */ const char *wanted = "name"; if (strcmp(wanted, "name") == 0) { printf("looked up: %s\n", person.name); } return 0; }
const person = { name: "Ada", age: 36 }; console.log(person.name + " is " + person.age); const wanted = "name"; console.log("looked up: " + person[wanted]); // computed at run time console.log("keys: " + Object.keys(person).join(", ")); console.log("has age? " + ("age" in person));
Because the key can be computed, the same construct serves as your symbol table, your configuration, and your JSON. Engines optimize the struct-like use heavily — an object whose shape never changes gets a hidden class and field access compiles to an offset load, exactly like C — which is why adding fields to an object after creating it is the one habit that quietly costs performance.
Fields Can Appear and Disappear
A C struct has exactly the fields its declaration lists, forever. A JavaScript object’s fields are entries in a table, so assigning to a name that is not there creates it and delete removes it.
#include <stdio.h> struct Config { int verbose; int retries; }; int main(void) { struct Config config = { 1, 3 }; printf("verbose %d retries %d\n", config.verbose, config.retries); /* config.timeout = 30; does not compile: no such member. Adding one means editing the struct and recompiling everything that includes it. */ return 0; }
const config = { verbose: true, retries: 3 }; console.log("verbose " + config.verbose + " retries " + config.retries); config.timeout = 30; // the field now exists console.log("timeout " + config.timeout); delete config.retries; console.log("retries after delete: " + config.retries); console.log("keys: " + Object.keys(config).join(", "));
The flexibility is real and so is the cost: a typo like config.retrys reads as undefined instead of failing to compile, and the assignment config.retrys = 5 creates a second field rather than correcting the first. This is the single most common source of silent bugs coming from C, and the reason large codebases either freeze their objects or move to TypeScript.
Map, For When the Key Is Not a String
Every key of a plain object is a string — object[1] and object["1"] are the same entry. Map is the hash table that keeps the key’s type and identity, which is what you want when the key is a number or an object.
#include <stdio.h> /* Keying by an integer in C means an array, or a hash function you supply, and the key type is whatever you decided it was. */ int main(void) { int counts[4] = { 0 }; counts[1] = 10; counts[2] = 20; printf("%d %d\n", counts[1], counts[2]); return 0; }
const asObject = {}; asObject[1] = "number one"; console.log("object key type: " + typeof Object.keys(asObject)[0]); console.log("object[\"1\"] is " + asObject["1"]); const asMap = new Map(); asMap.set(1, "number one"); asMap.set("1", "string one"); console.log("map size " + asMap.size); console.log("map.get(1) " + asMap.get(1)); console.log("map.get(\"1\") " + asMap.get("1"));
Map also keeps insertion order, accepts objects as keys (compared by identity, like a pointer), and has a real size rather than requiring Object.keys(...).length. Its sibling Set is the same idea without values. Use a plain object for a record with known fields and a Map for a lookup table whose keys arrive at run time.
Serialization Is One Call
Writing a C struct to a file means choosing a format and writing the reader and writer by hand — fwrite of the raw bytes is not portable across padding, alignment or endianness. JavaScript has one text format built into the language.
#include <stdio.h> struct Person { const char *name; int age; }; int main(void) { struct Person person = { "Ada", 36 }; /* Hand-written, because there is nothing built in. */ char encoded[64]; snprintf(encoded, sizeof encoded, "{\"name\":\"%s\",\"age\":%d}", person.name, person.age); printf("%s\n", encoded); return 0; }
const person = { name: "Ada", age: 36, tags: ["engineer", "writer"] }; const encoded = JSON.stringify(person); console.log(encoded); const decoded = JSON.parse(encoded); console.log(decoded.name + " has " + decoded.tags.length + " tags"); console.log(JSON.stringify(person, null, 2));
The C column only manages the flat case, and it would need escaping logic the moment a name contained a quote. What JSON cannot carry is worth knowing before you rely on it: no functions, no undefined (the key is dropped), no cycles (it throws), and a Date comes back as a string. For a byte-exact wire format you are back to the typed arrays in the next section.
class Instead of a Struct and Its Functions
The C pattern — a struct plus functions taking a pointer to it — has direct syntax here. What is underneath is not a vtable but a prototype: a shared object that instances delegate lookups to.
#include <stdio.h> struct Rectangle { double width; double height; }; static double rectangle_area(const struct Rectangle *self) { return self->width * self->height; } static void rectangle_scale(struct Rectangle *self, double factor) { self->width *= factor; self->height *= factor; } int main(void) { struct Rectangle shape = { 3.0, 4.0 }; printf("area %.1f\n", rectangle_area(&shape)); rectangle_scale(&shape, 2.0); printf("scaled area %.1f\n", rectangle_area(&shape)); return 0; }
class Rectangle { constructor(width, height) { this.width = width; this.height = height; } area() { return this.width * this.height; } scale(factor) { this.width *= factor; this.height *= factor; } } const shape = new Rectangle(3, 4); console.log("area " + shape.area().toFixed(1)); shape.scale(2); console.log("scaled area " + shape.area().toFixed(1));
this is the hidden first argument, exactly as &shape is in the C column. The methods live on Rectangle.prototype, one copy shared by every instance, and a lookup that misses on the instance walks up that chain — so this is closer to a hand-rolled inheritance-by-delegation scheme than to a compiled vtable. The class keyword is syntax over that mechanism, not a new one.
Functions Are Values That Carry State
A Function Is a Value
C has function pointers, so passing a function around is familiar — but the function itself is a fixed thing at a fixed address. Here a function is an ordinary value you can build at run time, store in an array, and attach properties to.
#include <stdio.h> static int doubled(int value) { return value * 2; } static int squared(int value) { return value * value; } int main(void) { int (*operations[2])(int) = { doubled, squared }; const char *names[2] = { "doubled", "squared" }; for (int index = 0; index < 2; index++) { printf("%s(7) = %d\n", names[index], operations[index](7)); } return 0; }
const doubled = value => value * 2; const squared = value => value * value; const operations = [doubled, squared]; for (const operation of operations) { console.log(operation.name + "(7) = " + operation(7)); } // Built at run time, which no C function pointer can be: const multiplier = factor => value => value * factor; const triple = multiplier(3); console.log("triple(7) = " + triple(7));
The function knows its own name, and multiplier(3) manufactures a genuinely new function that did not exist when the program started. That is the piece with no C equivalent: a function pointer can only ever point at code the compiler emitted, so the closest C gets is a struct holding a pointer plus the data it needs — which is what the next row is about.
A Closure Is a Function Pointer With Its Context Attached
Every C callback interface carries a void * alongside the function pointer, because the pointer cannot remember anything. A JavaScript function captures the variables that were in scope where it was written, so the extra parameter disappears from every layer.
#include <stdio.h> static void for_each(const int *values, size_t count, void (*action)(int value, void *context), void *context) { for (size_t index = 0; index < count; index++) { action(values[index], context); } } static void add_to_total(int value, void *context) { *(int *)context += value; } int main(void) { int numbers[] = { 1, 2, 3, 4 }; int total = 0; for_each(numbers, 4, add_to_total, &total); printf("total %d\n", total); return 0; }
const numbers = [1, 2, 3, 4]; let total = 0; numbers.forEach(value => { total += value; }); // total is captured console.log("total " + total); // A counter with private state — the C version needs a struct. function makeCounter() { let count = 0; return () => ++count; } const next = makeCounter(); console.log(next(), next(), next());
The captured variable is not copied — the closure holds the same binding, which is why total ends up at 10 and why makeCounter’s count survives after the function that declared it has returned. That last part is what a C local cannot do: the closure keeps the variable alive on the heap, and the collector frees it when the last function holding it goes away.
Every Argument Is Optional, and Extra Ones Are Ignored
A C call must match the declaration. Here the parameter list is a suggestion: missing arguments arrive as undefined, extra ones are dropped, and nothing complains at either end.
#include <stdio.h> static void greet(const char *name, const char *greeting) { printf("%s, %s!\n", greeting, name); } int main(void) { /* greet("Ada"); does not compile: too few arguments. */ greet("Ada", "Hello"); greet("Grace", "Welcome"); return 0; }
function greet(name, greeting = "Hello") { console.log(greeting + ", " + name + "!"); } greet("Ada"); greet("Grace", "Welcome"); greet("Alan", "Hi", "ignored", "also ignored"); function sumOf(...values) { // the rest parameter, not stdarg.h return values.reduce((total, value) => total + value, 0); } console.log(sumOf(1, 2, 3), sumOf());
The default value is evaluated at call time, in the function’s own scope, so function push(item, list = []) gets a fresh array on every call rather than sharing one. The rest parameter is a real array — it knows its length and has every array method — which is the whole difference from va_arg, where the count and the types are things you promised rather than things the language knows.
One Name, One Function — Check the Types Yourself
C puts the type in the function name (abs, labs, fabs) because it has one namespace. JavaScript has one namespace too, and no overloading either — so a function that handles several shapes inspects its arguments at run time.
#include <stdio.h> #include <stdlib.h> #include <math.h> int main(void) { printf("%d\n", abs(-5)); printf("%ld\n", labs(-5L)); printf("%.1f\n", fabs(-5.5)); return 0; }
console.log(Math.abs(-5)); console.log(Math.abs(-5.5)); // one function, because one number type function describe(value) { if (Array.isArray(value)) return "an array of " + value.length; if (typeof value === "number") return "a number: " + value; if (typeof value === "string") return "a string: " + value; return "something else: " + typeof value; } console.log(describe(42)); console.log(describe("text")); console.log(describe([1, 2]));
Redeclaring a function replaces it rather than adding an overload, silently — which is a real hazard in a file long enough that you forget. Math.abs needs only one form because there is only one number type, so the abs/labs/fabs family collapses; the price is that the dispatch you would have got from the type system is now an if ladder you maintain.
this Is Decided by the Call, Not the Definition
The hidden receiver argument is filled in by how a function is called, not by where it was defined. Pulling a method out of an object and calling it on its own therefore loses the receiver — a failure mode C cannot have, because you always pass the pointer yourself.
#include <stdio.h> struct Counter { int value; }; static void counter_bump(struct Counter *self) { self->value += 1; } int main(void) { struct Counter counter = { 0 }; void (*bump)(struct Counter *) = counter_bump; /* The receiver is an ordinary argument, so detaching the function from the struct changes nothing. */ bump(&counter); bump(&counter); printf("value %d\n", counter.value); return 0; }
const counter = { value: 0, bump() { this.value += 1; return this.value; }, }; console.log("as a method: " + counter.bump()); const detached = counter.bump; const result = detached(); // 'this' is no longer the counter console.log("detached returned: " + result); console.log("counter.value is still " + counter.value); const bound = counter.bump.bind(counter); console.log("bound: " + bound());
The detached call returns NaN rather than failing: outside strict mode this falls back to the global object, this.value is undefined, and undefined + 1 is NaN — so the increment silently went somewhere else. Inside a module or a class body, which are always strict, this is undefined instead and the same call throws, which is much easier to diagnose. An arrow function is the fix that removed most of this pain: it has no this of its own and uses the one from where it was written, which is why callbacks are almost always arrows now. When you do need to pin a normal function to a receiver, bind returns a new function with it attached — the closest thing in the language to writing the pointer argument out by hand.
Truthiness and the Coercion Rules
What Counts as False
C has one rule: zero is false, everything else is true. JavaScript has a fixed list of six falsy values, and the entries that are not on it are what catch a C programmer out.
#include <stdio.h> int main(void) { int values[] = { 0, 1, -1 }; const char *names[] = { "0", "1", "-1" }; for (int index = 0; index < 3; index++) { printf("%-3s -> %s\n", names[index], values[index] ? "true" : "false"); } const char *empty = ""; printf("\"\" -> %s\n", empty ? "true" : "false"); return 0; }
const cases = [0, 1, -1, "", "0", " ", [], {}, null, undefined, NaN]; for (const value of cases) { const shown = typeof value === "string" ? JSON.stringify(value) : Array.isArray(value) ? "[]" : value === null ? "null" : typeof value === "object" ? "{}" : String(value); console.log(shown.padEnd(10) + " -> " + Boolean(value)); }
The three that surprise everyone: the string "0" is truthy (it is a non-empty string), and both [] and {} are truthy because every object is. So if (list) does not test whether a list has anything in it — that is if (list.length). The full falsy list is false, 0, -0, "", null, undefined and NaN, plus 0n. Note that the C column has the mirror-image trap in its last line: a char * pointing at the empty string is a non-null pointer, so if (empty) is true there and false here.
== Converts First; === Does Not
C converts operands too — the usual arithmetic conversions — but only between numeric types, and never between a number and a string. JavaScript’s == will convert across types, following a table nobody remembers, which is why the language effectively has two equality operators and one of them is the one to use.
#include <stdio.h> #include <string.h> int main(void) { int number = 1; double also = 1.0; printf("1 == 1.0: %d\n", number == also); /* numeric conversion */ /* number == "1" does not compile: comparison between pointer and integer. There is no cross-type equality to be had. */ printf("strcmp: %d\n", strcmp("1", "1") == 0); return 0; }
console.log('1 == "1" ', 1 == "1"); console.log('1 === "1" ', 1 === "1"); console.log('0 == "" ', 0 == ""); console.log('0 == false ', 0 == false); console.log('null == 0 ', null == 0); console.log('null == undefined', null == undefined); console.log('NaN == NaN ', NaN == NaN); console.log('[] == false ', [] == false);
Read the last line twice: an empty array equals false under ==, while if ([]) takes the true branch. Both are consistent with the rules and neither is what anyone means. The working advice from every style guide is to use === everywhere, with the single exception of value == null, which is the idiomatic test for "null or undefined".
NaN Equals Nothing, Including Itself
This one is not a JavaScript quirk — it is IEEE 754, and your C already behaves the same way. What differs is how easy it is to produce a NaN here, because arithmetic on a non-number gives one instead of failing to compile.
#include <stdio.h> #include <math.h> int main(void) { double nothing = 0.0 / 0.0; printf("nan == nan: %d\n", nothing == nothing); printf("isnan: %d\n", isnan(nothing)); /* Producing one takes deliberate effort — the type system will not let a string into the arithmetic. */ return 0; }
const nothing = 0 / 0; console.log("NaN === NaN: " + (nothing === nothing)); console.log("Number.isNaN: " + Number.isNaN(nothing)); // The easy ways to make one by accident: console.log('"abc" * 2 -> ' + ("abc" * 2)); console.log("[1,2][5] + 1 -> " + ([1, 2][5] + 1)); console.log("parseInt(\"x\") -> " + parseInt("x")); console.log("[NaN].includes(NaN) -> " + [NaN].includes(NaN));
Because NaN is not equal to itself, indexOf can never find one — but includes can, since it uses a slightly different comparison. Use Number.isNaN rather than the older global isNaN, which converts its argument first and therefore claims that the string "abc" is NaN. A NaN spreading through a calculation is the usual symptom of the "out of range reads as undefined" behavior two sections up.
?. and ?? Instead of a Ladder of Null Checks
Reaching into a nested structure in C means checking every pointer on the way down. Two operators collapse that: ?. stops and yields undefined if the left side is absent, and ?? supplies a default only when the value is null or undefined.
#include <stdio.h> struct Address { const char *city; }; struct Person { const char *name; struct Address *address; }; int main(void) { struct Person known = { "Ada", NULL }; /* Every level needs its own check. */ const char *city = "(unknown)"; if (known.address != NULL && known.address->city != NULL) { city = known.address->city; } printf("%s lives in %s\n", known.name, city); return 0; }
const person = { name: "Ada", address: null }; console.log(person.name + " lives in " + (person.address?.city ?? "(unknown)")); const other = { name: "Grace", address: { city: "Arlington" } }; console.log(other.name + " lives in " + (other.address?.city ?? "(unknown)")); // ?? differs from || exactly where it matters: console.log("0 || 5 -> " + (0 || 5)); console.log("0 ?? 5 -> " + (0 ?? 5));
The last two lines are the reason ?? was added: || falls back on every falsy value, so a legitimate 0 or "" gets replaced by the default. ?? falls back only on the two absent values, which is almost always what a default is meant to mean. ?. also works before a call (callback?.()) and before a subscript (list?.[0]).
Scope, Hoisting, and the Loop Trap
let Is Block-Scoped; var Is Not
C locals are block-scoped, and so are let and const. The older var is scoped to the whole enclosing function and is hoisted — the name exists from the top of the function, holding undefined until the declaration runs.
#include <stdio.h> int main(void) { int outer = 1; { int outer = 2; /* a different variable, this block only */ printf("inside %d\n", outer); } printf("outside %d\n", outer); /* printf("%d\n", later); before the declaration does not compile. */ int later = 3; printf("later %d\n", later); return 0; }
let outer = 1; { let outer = 2; // a different variable, this block only console.log("inside " + outer); } console.log("outside " + outer); function withVar() { console.log("var before its declaration: " + hoisted); var hoisted = 3; if (true) { var leaked = 4; } console.log("var escapes its block: " + leaked); } withVar();
Reading a let before its declaration throws instead — the "temporal dead zone" — which is the behavior you want and the reason var is effectively deprecated. The practical rule for arriving from C: use const by default, let when the name must be reassigned, and never var.
The Loop Variable Every Callback Shares
This is the most famous bug in the language and it is worth seeing next to C, where it cannot happen: a callback made inside a var loop captures the variable, not its value, and there is only one of it.
#include <stdio.h> /* C has no closures, so the value must be carried explicitly — which is exactly why the bug has nowhere to occur. */ struct Task { int index; }; int main(void) { struct Task tasks[3]; for (int index = 0; index < 3; index++) { tasks[index].index = index; /* the value is copied */ } for (int index = 0; index < 3; index++) { printf("task %d\n", tasks[index].index); } return 0; }
const withVar = []; for (var index = 0; index < 3; index++) { withVar.push(() => index); // all three capture the same variable } console.log("var: " + withVar.map(task => task()).join(" ")); const withLet = []; for (let index = 0; index < 3; index++) { withLet.push(() => index); // a fresh binding each iteration } console.log("let: " + withLet.map(task => task()).join(" "));
The var line prints 3 3 3: one variable, shared by all three closures, holding the value that ended the loop. let in a for header is special-cased by the language to create a new binding per iteration, so it prints 0 1 2. Before let existed the workaround was to wrap the body in an immediately-invoked function, which is the same trick as copying the value into a struct in the C column.
No goto, But Labels Still Break Out of Nested Loops
There is no goto at all. The one use of it that has no clean alternative — leaving a nested loop from the inside — is kept as a labeled break, which is the same construct without the ability to jump anywhere else.
#include <stdio.h> int main(void) { int grid[3][3] = { {1,2,3}, {4,5,6}, {7,8,9} }; for (int row = 0; row < 3; row++) { for (int column = 0; column < 3; column++) { if (grid[row][column] == 5) { printf("found at %d,%d\n", row, column); goto done; } } } done: printf("search finished\n"); return 0; }
const grid = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]; search: for (let row = 0; row < grid.length; row++) { for (let column = 0; column < grid[row].length; column++) { if (grid[row][column] === 5) { console.log("found at " + row + "," + column); break search; } } } console.log("search finished");
A labeled continue exists too, and jumps to the next iteration of the labeled loop. What is missing is jumping forward to a cleanup label, which is C’s other main use of goto — that job belongs to try/finally, two sections down. There are no other jumps: you cannot label an arbitrary statement and go to it.
Nothing to free, and Everything Aliases
Nothing Is Allocated, and Nothing Is Freed
There is no malloc to call, no size to compute, and no free to match. An object exists as long as something can reach it, and the collector reclaims it some time after nothing can.
#include <stdio.h> #include <stdlib.h> #include <string.h> struct Person { char *name; int age; }; int main(void) { struct Person *person = malloc(sizeof *person); if (person == NULL) return 1; person->name = malloc(4); if (person->name == NULL) { free(person); return 1; } strcpy(person->name, "Ada"); person->age = 36; printf("%s is %d\n", person->name, person->age); free(person->name); /* the inner one first */ free(person); return 0; }
const person = { name: "Ada", age: 36 }; console.log(person.name + " is " + person.age); // Nothing to free. Dropping the last reference is all there is: let temporary = { large: new Array(1000).fill(0) }; temporary = null; console.log("dropped; the collector will take it when it likes");
The three C failure modes — leak, double free, use-after-free — all disappear, and so does the ownership question that shapes every C interface: nothing needs to document who frees what. What replaces the leak is the retained reference: a value kept in a long-lived array, a closure, or an event listener nobody removed is still reachable and therefore still alive. That is the leak you will actually hunt.
Assigning an Object Copies the Reference
This is C pointer semantics with no * and no -> to announce it. Primitives copy; everything else — objects, arrays, functions — is a reference, and assigning one gives you a second name for one thing.
#include <stdio.h> struct Counter { int value; }; int main(void) { struct Counter counter = { 1 }; struct Counter copy = counter; /* a real copy */ copy.value = 99; printf("struct copy: %d\n", counter.value); struct Counter *alias = &counter; /* a reference, and it shows */ alias->value = 99; printf("via pointer: %d\n", counter.value); return 0; }
let number = 1; let numberCopy = number; numberCopy = 99; console.log("primitive copy: " + number); const counter = { value: 1 }; const alias = counter; // no punctuation says this aliases alias.value = 99; console.log("object alias: " + counter.value); const shallow = { ...counter }; // a one-level copy shallow.value = 1; console.log("after spread: " + counter.value);
The { ...object } spread is a shallow copy — one level deep, so a nested object is still shared, exactly like copying a C struct that contains a pointer. Function arguments follow the same rule: the reference is passed by value, so reassigning the parameter inside the function changes nothing outside, but mutating what it points at does.
Copying All the Way Down
A deep copy in C is a recursive walk you write, allocating as you go. JavaScript has one built in, and knowing where it stops is the useful part.
#include <stdio.h> #include <stdlib.h> #include <string.h> struct Settings { char *title; int retries; }; static struct Settings *settings_clone(const struct Settings *source) { struct Settings *copy = malloc(sizeof *copy); if (copy == NULL) return NULL; copy->title = malloc(strlen(source->title) + 1); if (copy->title == NULL) { free(copy); return NULL; } strcpy(copy->title, source->title); copy->retries = source->retries; return copy; } int main(void) { struct Settings original = { "report", 3 }; struct Settings *copy = settings_clone(&original); copy->retries = 9; printf("original %d, copy %d\n", original.retries, copy->retries); free(copy->title); free(copy); return 0; }
const original = { title: "report", limits: { retries: 3 } }; const shallow = { ...original }; shallow.limits.retries = 9; console.log("after shallow copy, original is " + original.limits.retries); original.limits.retries = 3; const deep = structuredClone(original); deep.limits.retries = 9; console.log("after deep copy, original is " + original.limits.retries);
structuredClone handles cycles, Map, Set, Date and typed arrays, which is more than the old JSON.parse(JSON.stringify(x)) trick ever did. What it will not clone is a function, a DOM node, or a class instance’s prototype — it throws on the first and returns a plain object for the last. The shallow line above is the bug it exists to prevent, and it is the same bug as copying a C struct full of pointers with memcpy.
throw Instead of a Return Code
throw Instead of a Return Code
A returned status can be ignored and usually is. A thrown value unwinds the stack until something catches it, so the failure path runs whether or not the caller wrote code for it.
#include <stdio.h> #include <stdlib.h> #include <errno.h> int main(void) { const char *input = "not a number"; char *end = NULL; errno = 0; long parsed = strtol(input, &end, 10); if (end == input || *end != '\0') { printf("parse failed, and only the check found out\n"); } else { printf("parsed %ld\n", parsed); } /* Ignoring the result compiles silently: */ strtol("also bad", NULL, 10); printf("ignored, program continues\n"); return 0; }
function parseCount(text) { const value = Number(text); if (Number.isNaN(value)) { throw new TypeError(`"${text}" is not a number`); } return value; } try { console.log(parseCount("42")); console.log(parseCount("not a number")); } catch (error) { console.log(error.constructor.name + ": " + error.message); } // The quiet alternative the language itself uses: console.log("Number(\"bad\") is " + Number("bad"));
The catch is that the language’s own conversions do not throw — Number("bad") gives NaN, parseInt("12abc") gives 12, and an out-of-range subscript gives undefined. So arriving from C you get exceptions where your code raises them and silent bad values everywhere else, which is why validating at the boundary matters more here than it does in a language that checks types for you.
finally Instead of goto cleanup
The goto cleanup ladder handles failures this function noticed. finally handles those too, and also the ones thrown three frames further down — which is the part the ladder cannot do.
#include <stdio.h> #include <stdlib.h> int main(void) { char *first = NULL, *second = NULL; int result = 1; first = malloc(16); if (first == NULL) goto cleanup; second = malloc(16); if (second == NULL) goto cleanup; printf("both acquired\n"); result = 0; cleanup: free(second); free(first); printf("cleanup ran, result %d\n", result); return 0; }
function work() { try { console.log("both acquired"); throw new Error("something failed deeper down"); } catch (error) { console.log("caught: " + error.message); return "handled"; } finally { console.log("cleanup ran"); } } console.log("result: " + work());
Note the ordering the output proves: finally runs after the return value has been computed but before the function actually returns. That makes it the right place for closing a file or releasing a lock, and the wrong place for a return of its own — a return inside finally silently replaces the one that was on its way out, including replacing an in-flight exception.
An Error Carries a Message, a Type, and a Stack
An errno is an integer, and everything else the failure knew has to be looked up or passed separately. A thrown Error is an object: it has a class, a message, a stack trace, and room for whatever else you attach.
#include <stdio.h> #include <errno.h> #include <string.h> int main(void) { FILE *handle = fopen("/tmp/definitely-not-here-c-javascript", "r"); if (handle == NULL) { printf("errno %d: %s\n", errno, strerror(errno)); } return 0; }
class ValidationError extends Error { constructor(field, message) { super(message); this.name = "ValidationError"; this.field = field; } } try { throw new ValidationError("age", "must be a positive number"); } catch (error) { console.log(error.name + " on " + error.field + ": " + error.message); console.log("is an Error: " + (error instanceof Error)); console.log("has a stack: " + (typeof error.stack === "string")); }
A catch clause catches everything and then sorts it out with instanceof, since there are no typed catch clauses. Anything at all can be thrown — a string, a number — but do not: only an Error carries a stack, and losing the stack is the difference between a five-minute diagnosis and an afternoon.
Nothing Blocks: One Thread, One Queue
There Is No sleep, Because Nothing Waits
A C program that waits stops. JavaScript has one thread and a queue: work that will finish later is registered as a callback, the current function runs to the end, and the queued work happens after that.
#include <stdio.h> #include <unistd.h> int main(void) { printf("first\n"); /* The whole program stops here for a second. */ sleep(1); printf("second\n"); printf("third\n"); return 0; }
console.log("first"); setTimeout(() => console.log("third — the timer callback"), 0); Promise.resolve().then(() => console.log("second — the microtask")); console.log("last line of the main run, printed before either");
Read the output order rather than the source order: everything synchronous finishes first, then all queued promise callbacks (microtasks), then the timer callbacks (macrotasks) — which is why a setTimeout(fn, 0) still runs after a promise that resolved much later in the source. There is no blocking sleep in the language at all, deliberately: on a page it would freeze the interface, and the queue is what replaces it.
await Is How Sequential Code Comes Back
Callbacks give you the concurrency but ruin the shape of the code. async/await gives the shape back: the function suspends at each await and resumes when the value is ready, while the thread goes off and does other work in between.
#include <stdio.h> #include <unistd.h> static int fetch_value(int identifier) { sleep(0); /* stands in for real work */ return identifier * 10; } int main(void) { /* Blocking and sequential: each call finishes before the next. */ int first = fetch_value(1); int second = fetch_value(2); printf("%d %d\n", first, second); return 0; }
function fetchValue(identifier) { return new Promise(resolve => setTimeout(() => resolve(identifier * 10), 10)); } (async () => { // Sequential: the second request starts after the first finishes. const first = await fetchValue(1); const second = await fetchValue(2); console.log("sequential: " + first + " " + second); // Concurrent: both are in flight before either is awaited. const [left, right] = await Promise.all([fetchValue(3), fetchValue(4)]); console.log("concurrent: " + left + " " + right); })();
The async wrapper around the whole example is only needed because await must be inside an async function in a script; inside a module it works at the top level. Promise.all is the important habit — two awaits in a row are sequential, which is usually a mistake when the two operations do not depend on each other. Nothing here is a second thread: it is one thread interleaving.
One Thread — a Long Loop Stops Everything
Because there is one thread, a computation that takes a second blocks every timer, every event and, in a browser, every pixel of the interface for that second. The concurrency is cooperative, and the cooperation is yours to provide.
#include <stdio.h> int main(void) { /* A C program that wanted to stay responsive would hand this to another thread and carry on; there is exactly one thread on the other side of this page. */ double total = 0.0; for (long index = 0; index < 20000000L; index++) { total += index; } printf("total %.0f\n", total); printf("meanwhile another thread could have kept working\n"); return 0; }
const timerStarted = Date.now(); setTimeout(() => { console.log("timer fired " + (Date.now() - timerStarted) + "ms late"); }, 0); let total = 0; for (let index = 0; index < 20000000; index++) { total += index; } console.log("total " + total); console.log("the timer above could not run until this line finished");
The timer was asked for zero milliseconds and fires only after the loop completes, because the queue is not consulted until the current work returns. The real answers are to break the work into chunks that yield between them, or to move it off the thread entirely — a Web Worker in the browser, a worker thread in Node — which communicates by copying messages rather than by sharing memory, so none of the data-race problems you know from C threads apply.
A Function That Suspends and Resumes
A C function that has to produce values one at a time keeps its position in a static variable or in a struct you pass back in. A generator keeps its own position: it stops at each yield and continues from there when asked for the next value.
#include <stdio.h> /* The state has to live somewhere the caller keeps. */ struct FibonacciState { long current; long next; }; static long fibonacci_next(struct FibonacciState *state) { long value = state->current; long following = state->current + state->next; state->current = state->next; state->next = following; return value; } int main(void) { struct FibonacciState state = { 0, 1 }; for (int index = 0; index < 8; index++) { printf("%ld ", fibonacci_next(&state)); } printf("\n"); return 0; }
function* fibonacci() { let current = 0, next = 1; while (true) { yield current; [current, next] = [next, current + next]; } } const values = []; for (const value of fibonacci()) { if (values.length === 8) break; values.push(value); } console.log(values.join(" "));
The generator is an infinite sequence and the loop simply stops taking values, which is safe because nothing is computed until it is asked for. The mechanism is the same one async/await is built on — a function whose frame survives being suspended — and it is the closest the language comes to the coroutine you would have to hand-roll as a state machine in C.
Real Bytes: ArrayBuffer and DataView
ArrayBuffer Is a Block of Bytes
Everything above has been about a language with no addresses and no widths. An ArrayBuffer is the exception: a fixed-size block of raw bytes, allocated once, that you read through a typed view — which is malloc plus a cast, spelled safely.
#include <stdio.h> #include <stdlib.h> #include <string.h> int main(void) { unsigned char *block = calloc(8, 1); if (block == NULL) return 1; block[0] = 0xFF; block[1] = 0x01; printf("bytes %d %d %d\n", block[0], block[1], block[2]); printf("size 8\n"); free(block); return 0; }
const buffer = new ArrayBuffer(8); const bytes = new Uint8Array(buffer); bytes[0] = 0xFF; bytes[1] = 0x01; console.log("bytes " + bytes[0] + " " + bytes[1] + " " + bytes[2]); console.log("size " + buffer.byteLength); console.log("elements " + bytes.length); bytes[0] = 300; // wraps, like an unsigned char console.log("300 stored in a Uint8Array reads back as " + bytes[0]);
The buffer is zero-filled at creation, so it is calloc rather than malloc, and its size can never change. A typed array clamps to its element type on write — storing 300 in a Uint8Array gives 44, exactly as assigning to an unsigned char would — and it bounds-checks on both read and write, so the worst an out-of-range index can do is read undefined.
Two Views Over One Buffer Is a Union
Several typed arrays can share one ArrayBuffer, each interpreting the same bytes as a different type. That is precisely a C union, and it is the supported way to reinterpret bits here — no strict-aliasing rule to violate, because the buffer is the object and the views are just lenses.
#include <stdio.h> #include <stdint.h> union Reinterpret { uint32_t whole; uint8_t bytes[4]; }; int main(void) { union Reinterpret value; value.whole = 0x01020304u; printf("as uint32: %u\n", value.whole); printf("as bytes: %d %d %d %d\n", value.bytes[0], value.bytes[1], value.bytes[2], value.bytes[3]); return 0; }
const buffer = new ArrayBuffer(4); const asWord = new Uint32Array(buffer); const asBytes = new Uint8Array(buffer); asWord[0] = 0x01020304; console.log("as uint32: " + asWord[0]); console.log("as bytes: " + Array.from(asBytes).join(" ")); const asFloat = new Float32Array(buffer); console.log("the same four bytes as a float: " + asFloat[0]);
Both columns print the bytes in the machine’s own order — 4 3 2 1 on anything little-endian — because both are looking straight at memory. That is the trap this makes easy to fall into: a typed array is fast precisely because it uses the platform order, so it is the wrong tool for a file or network format. The next row is the right one.
DataView: Endianness You Choose
Reading a wire format in C means either trusting the platform or calling htonl and friends. DataView makes the choice explicit at every access — the byte order is an argument, so there is no default to be wrong about.
#include <stdio.h> #include <stdint.h> #include <string.h> int main(void) { unsigned char wire[4]; uint32_t value = 0x01020304u; /* Big-endian on the wire, by hand. */ wire[0] = (unsigned char)(value >> 24); wire[1] = (unsigned char)(value >> 16); wire[2] = (unsigned char)(value >> 8); wire[3] = (unsigned char)value; printf("wire bytes: %d %d %d %d\n", wire[0], wire[1], wire[2], wire[3]); uint32_t decoded = ((uint32_t)wire[0] << 24) | ((uint32_t)wire[1] << 16) | ((uint32_t)wire[2] << 8) | (uint32_t)wire[3]; printf("decoded: %u\n", decoded); return 0; }
const buffer = new ArrayBuffer(4); const view = new DataView(buffer); view.setUint32(0, 0x01020304, false); // false = big-endian console.log("wire bytes: " + Array.from(new Uint8Array(buffer)).join(" ")); console.log("decoded: " + view.getUint32(0, false)); view.setUint32(0, 0x01020304, true); // true = little-endian console.log("little-endian bytes: " + Array.from(new Uint8Array(buffer)).join(" "));
The two columns print the same four wire bytes, because both are writing big-endian deliberately. DataView also reads and writes at any byte offset, aligned or not, which a typed array cannot do — new Uint32Array(buffer, 1) throws. For parsing a binary format, DataView is the tool; typed arrays are for bulk data you produced yourself.
Turning Text Into Bytes
A C string already is bytes, in whatever encoding your source file used. A JavaScript string is UTF-16 with no byte view at all, so producing bytes is an explicit encode — and that is the step to remember at any boundary where a length is measured in bytes.
#include <stdio.h> #include <string.h> int main(void) { const char *text = "caf\u00e9"; /* UTF-8 in the source file */ size_t length = strlen(text); printf("bytes %zu\n", length); for (size_t index = 0; index < length; index++) { printf("%02x ", (unsigned char)text[index]); } printf("\n"); return 0; }
const text = "caf\u00e9"; console.log("string length " + text.length); const bytes = new TextEncoder().encode(text); // always UTF-8 console.log("bytes " + bytes.length); console.log(Array.from(bytes, one => one.toString(16).padStart(2, "0")).join(" ")); const roundTrip = new TextDecoder().decode(bytes); console.log("round trip: " + roundTrip);
TextEncoder only ever produces UTF-8 — there is no option — while TextDecoder can be told to read other encodings. The gap the output shows is the whole hazard: four characters, five bytes. Any protocol whose length field counts bytes must be given the encoded length, not string.length, and that mistake is the JavaScript version of confusing strlen with a character count.
Packing a Struct By Hand
There is no struct layout to rely on, so a binary record is assembled field by field at chosen offsets. That is more typing than fwrite(&record, sizeof record, 1, file), and it is also the version that survives a change of compiler, platform or alignment.
#include <stdio.h> #include <stdint.h> #include <string.h> struct Record { uint16_t kind; uint32_t length; }; int main(void) { struct Record record = { 7, 1024 }; /* Portable encoding, big-endian, written out by hand — writing the struct directly would leak padding and byte order. */ unsigned char wire[6]; wire[0] = (unsigned char)(record.kind >> 8); wire[1] = (unsigned char)record.kind; wire[2] = (unsigned char)(record.length >> 24); wire[3] = (unsigned char)(record.length >> 16); wire[4] = (unsigned char)(record.length >> 8); wire[5] = (unsigned char)record.length; printf("packed %zu bytes:", sizeof wire); for (size_t index = 0; index < sizeof wire; index++) { printf(" %02x", wire[index]); } printf("\n"); printf("sizeof(struct Record) is %zu, with padding\n", sizeof(struct Record)); return 0; }
const buffer = new ArrayBuffer(6); const view = new DataView(buffer); view.setUint16(0, 7, false); // kind view.setUint32(2, 1024, false); // length const packed = Array.from(new Uint8Array(buffer), one => one.toString(16).padStart(2, "0")); console.log("packed 6 bytes: " + packed.join(" ")); console.log("kind " + view.getUint16(0, false)); console.log("length " + view.getUint32(2, false));
The two columns produce the same six bytes, and the C column also prints sizeof(struct Record) — 8, not 6, because the compiler padded length to a four-byte boundary. That gap is exactly why writing a struct straight to a file is not a format. JavaScript has no way to make that mistake, since there is no struct to write.
Modules, and Where C Comes Back
Modules Instead of #include
A #include pastes text and needs a guard so it survives being pasted twice. A module is loaded once, evaluated once, and exports named values — there is no textual inclusion, so there are no guards, no header/source split, and no order dependence.
/* geometry.h — the header, with its guard */ #ifndef GEOMETRY_H #define GEOMETRY_H double circle_area(double radius); #endif /* main.c */ #include <stdio.h> #include "geometry.h" int main(void) { printf("%.4f\n", circle_area(1.0)); return 0; }
// geometry.js export function circleArea(radius) { return Math.PI * radius * radius; } // main.js import { circleArea } from "./geometry.js"; console.log(circleArea(1).toFixed(4));
Imports are hoisted and resolved before any code runs, so the order of the statements does not matter and a circular import resolves rather than looping forever. Node also still supports the older require form, which is a function call and therefore can be conditional — the two systems interoperate awkwardly, and which one a file uses is decided by its extension and its package settings.
WebAssembly: Your C, Running Here
This is the destination the byte-level section points at. A compiled module is loaded from bytes, instantiated, and its exports are called like ordinary functions — the same mechanism that lets a C or Rust library run inside a page.
#include <stdio.h> /* Compiled to WebAssembly (clang --target=wasm32, or emscripten), this function becomes the exported "add" the other column calls. */ int add(int left, int right) { return left + right; } int main(void) { printf("add(2, 40) = %d\n", add(2, 40)); return 0; }
// A complete WebAssembly module, hand-assembled: it exports one // function, add(i32, i32) -> i32. This is what a compiler emits. const moduleBytes = new Uint8Array([ 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic + version 0x01, 0x07, 0x01, 0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7f, // type: (i32,i32)->i32 0x03, 0x02, 0x01, 0x00, // one function 0x07, 0x07, 0x01, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00, // export "add" 0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, // body: get 0, get 1, 0x6a, 0x0b, // i32.add, end ]); (async () => { const { instance } = await WebAssembly.instantiate(moduleBytes); console.log("add(2, 40) = " + instance.exports.add(2, 40)); console.log("typeof the export: " + typeof instance.exports.add); })();
Both columns print 42, and the second one is running the same computation the first one compiles to. What crosses the boundary is only numbers: to pass a string or a struct you write it into the module’s linear memory — which is an ArrayBuffer, viewable with exactly the typed arrays from the previous section — and pass the offset. That is the whole interop model, and it is why those rows come first.