Output & Running
Hello, World — and no entry point
A JavaScript file has no
package, no main and no import block. The file is the program: statements run top to bottom the moment the engine reaches them.package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}console.log("Hello, World!");There is no compile step, so nothing is checked before it runs — including whether a function exists.
console.log takes several arguments and separates them with spaces, so it is closer to fmt.Println than to fmt.Print, and it writes to stdout while console.error writes to stderr. An unused import is a compile error in Go and does not exist as a concept here; an unused variable is likewise only a linter's opinion.Formatting: no verbs, and no %v
Template literals interpolate any expression, which covers
%s and %d. Everything after the verb — width, precision, alignment — has no format string to live in.package main
import "fmt"
func main() {
name := "Ada"
scores := []int{90, 85}
fmt.Printf("%s scored %v\n", name, scores)
fmt.Printf("%8.2f\n", 3.14159)
fmt.Printf("%+v\n", struct{ X, Y int }{1, 2})
}const name = "Ada";
const scores = [90, 85];
console.log(`${name} scored ${JSON.stringify(scores)}`);
console.log((3.14159).toFixed(2).padStart(8));
console.log(JSON.stringify({ x: 1, y: 2 }));Width and precision are method calls applied to the value before it reaches the string:
toFixed, padStart, toLocaleString. There is no %v and no %+v: console.log of an object prints an engine-specific rendering that differs between Node and browsers, so anything you actually depend on goes through JSON.stringify. There is also no Stringer interface that console.log consults — it ignores toString for objects, which surprises everyone once.Nothing is checked before it runs
Go's compiler is famously fast and famously strict — unused variables and imports are errors. JavaScript checks nothing at all before execution reaches the line.
package main
import "fmt"
func main() {
count := 3
// fmt.Println(count + "one") // uncomment: mismatched types, no binary
fmt.Println(count)
}const count = 3;
console.log(count + "one"); // "3one" — a defined operation, not an error
try {
count.toFixd(2); // the typo is invisible until this line runs
} catch (error) {
console.log(error.constructor.name + ": count.toFixd is not a function");
}Adding a number to a string is a defined operation rather than a type mismatch, and a misspelled method is a
TypeError found only if that branch runs. Two consequences for a Go author: your test coverage is now the type checker, and TypeScript is how the ecosystem gets some of it back — the same language with a checker on top, erased before execution. If you are shipping a library the JavaScript world consumes, shipping .d.ts type declarations alongside it is the equivalent of exporting a documented API.Goroutines Meet One Event Loop
go f() has no counterpart
This is the row the page exists for, so it comes almost first. There is no
go statement, no scheduler you can reason about, and no second thread to run anything on.package main
import (
"fmt"
"sync"
)
func main() {
var waitGroup sync.WaitGroup
results := make([]int, 3)
for index := 0; index < 3; index++ {
waitGroup.Add(1)
go func(index int) {
defer waitGroup.Done()
results[index] = index * 10
}(index)
}
waitGroup.Wait()
fmt.Println(results)
}(async () => {
const work = async (index) => index * 10;
// No "go" statement. This is concurrent, not parallel: all three start,
// and only I/O actually overlaps.
const results = await Promise.all([0, 1, 2].map(work));
console.log(results.join(","));
})();Promise.all is the shape a WaitGroup becomes, and it is not the same thing. Three goroutines genuinely run at once on separate cores; three promises interleave on one thread, so they overlap only where they wait on I/O. CPU work does not overlap at all. The mental substitution to make: stop asking "how many goroutines" and start asking "how many things are waiting on the network", because that is the only axis on which JavaScript concurrency exists.Blocking the one thread stops everything
A Go program that starts a heavy goroutine keeps serving; the runtime preempts it and everything else carries on. A JavaScript program that starts a heavy loop is unavailable until it finishes.
package main
import (
"fmt"
"sync"
)
func main() {
var waitGroup sync.WaitGroup
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
total := 0
for value := 0; value < 5000000; value++ {
total += value
}
fmt.Println("worker finished", total)
}()
fmt.Println("main continues")
waitGroup.Wait()
}let total = 0;
for (let value = 0; value < 5_000_000; value++) total += value;
console.log("worker finished", total);
console.log("main continues"); // ...only now, and not one instant soonerRead the order the two columns printed in: Go says main continues first, JavaScript says it last. That inversion is the whole row. In a browser the page stops responding to clicks and stops painting; in Node every other request waits. There is no preemption, because there is nothing to preempt to. This is the single most important operational difference for anyone porting a Go service to Node: a request handler that does real computation is a production incident, and the fix is to move the work off the thread entirely — a Web Worker, a
worker_threads pool, a queue, or (the reason the last section of this page exists) a WASM module.Channels become a promise, or a queue you write
There is no channel type. An async generator consumed with
for await...of is the closest thing: a producer that suspends until the consumer asks for more.package main
import "fmt"
func produce(out chan<- int) {
for value := 1; value <= 3; value++ {
out <- value
}
close(out)
}
func main() {
values := make(chan int)
go produce(values)
for value := range values {
fmt.Println("got", value)
}
}(async () => {
// The closest shape: an async generator. One producer, one consumer,
// backpressure by construction — and no second thread behind it.
async function* produce() {
for (let value = 1; value <= 3; value++) yield value;
}
for await (const value of produce()) {
console.log("got", value);
}
})();What it gives you is the unbuffered, one-producer/one-consumer case with backpressure, and
return from the generator is close. What it does not give you is any of the rest: no buffered channels, no multiple senders, no select, and no fan-in. A real queue with several producers is a small class you write around an array and a list of waiting resolvers, or a library (p-queue, async). Note also that a generator is pull-driven where a channel is push-driven, so a producer that must run ahead of its consumer needs an explicit buffer.select becomes Promise.race, and is not the same thing
Promise.race settles with the first outcome, which looks like select and differs in three ways that matter.package main
import (
"fmt"
"time"
)
func main() {
fast := make(chan string, 1)
slow := make(chan string, 1)
go func() {
time.Sleep(10 * time.Millisecond)
fast <- "fast"
}()
go func() {
time.Sleep(100 * time.Millisecond)
slow <- "slow"
}()
select {
case winner := <-fast:
fmt.Println("winner:", winner)
case winner := <-slow:
fmt.Println("winner:", winner)
}
}(async () => {
const after = (milliseconds, value) =>
new Promise((resolve) => setTimeout(() => resolve(value), milliseconds));
const winner = await Promise.race([after(10, "fast"), after(100, "slow")]);
console.log("winner:", winner);
// The loser keeps running. Nothing cancels it, and if it rejects later
// that rejection is unhandled.
})();First, the loser is not cancelled — it keeps running, and if it rejects afterwards you get an unhandled rejection from work whose result you discarded. Second, there is no
default clause, so there is no non-blocking poll. Third, a promise settles once, where a channel yields repeatedly, so select in a loop has no counterpart at all — that becomes an event emitter or an async iterator. Promise.any is the "first success" variant, which select has no equivalent of.No mutex, because there is no data race
This is the compensation for everything the previous rows took away.
sync.Mutex, sync.RWMutex, sync/atomic, the memory model and go test -race all have no counterpart — because the problem they solve cannot arise.package main
import (
"fmt"
"sync"
)
func main() {
var lock sync.Mutex
var waitGroup sync.WaitGroup
total := 0
for worker := 0; worker < 4; worker++ {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
for step := 0; step < 1000; step++ {
lock.Lock()
total++
lock.Unlock()
}
}()
}
waitGroup.Wait()
fmt.Println(total)
}let total = 0;
// No lock, and none needed: two lines of your code never run at the
// same instant, so ++ cannot interleave.
for (let worker = 0; worker < 4; worker++) {
for (let step = 0; step < 1000; step++) total++;
}
console.log(total);A JavaScript function runs to completion before any other code runs (run-to-completion semantics), so
total++ can never interleave and there is no torn read to protect against. What replaces the concern is logical races across await points: an await is a yield, so state you read before it may have changed by the time you resume, and two overlapping requests can interleave between awaits. The discipline shifts from "protect the memory" to "re-check your assumptions after every await".Web Workers: the closest thing to a goroutine, sharing nothing
Web Workers (in a browser) and
worker_threads (in Node) are the only way to get a second thread, and they are the opposite of a goroutine in the one respect that matters.package main
import "fmt"
func main() {
// Goroutines share the heap: this slice is visible to every one of them,
// which is why sync exists.
shared := []int{1, 2, 3}
done := make(chan bool)
go func() {
shared = append(shared, 4)
done <- true
}()
<-done
fmt.Println(shared)
}// A Worker is a separate JavaScript realm. Nothing is shared:
// const worker = new Worker("./work.js");
// worker.postMessage({ numbers: [1, 2, 3] }); // structured-CLONED
// worker.onmessage = (event) => console.log(event.data);
// The worker gets a COPY. Mutating it there changes nothing here.
const shared = [1, 2, 3];
shared.push(4);
console.log(shared.join(","));A goroutine shares the heap, which is why
sync exists and why -race is worth running. A worker shares nothing: messages are structured-cloned across the boundary, so passing a large array copies it, and functions and class instances do not survive the trip at all. The exceptions are ArrayBuffer (transferable — moved, not copied) and SharedArrayBuffer plus Atomics, which is real shared memory and is gated behind cross-origin isolation headers. Design for message passing with coarse messages, not for shared state.context.Context becomes AbortController
The cancellation pattern survives the move, and its ergonomics do not.
AbortController is the platform's context.Context: you create one, pass its signal down, and check it.package main
import (
"context"
"fmt"
"time"
)
func work(ctx context.Context) error {
for step := 0; step < 5; step++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
time.Sleep(10 * time.Millisecond)
}
}
return nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
defer cancel()
fmt.Println("stopped:", work(ctx))
}(async () => {
const work = async (signal) => {
for (let step = 0; step < 5; step++) {
await new Promise((resolve) => setTimeout(resolve, 10));
if (signal.aborted) return signal.reason; // cooperative, and manual
}
return null;
};
const controller = new AbortController();
setTimeout(() => controller.abort(new Error("context deadline exceeded")), 25);
const outcome = await work(controller.signal);
console.log("stopped:", outcome ? outcome.message : "completed");
})();The mapping is close —
ctx.Done() is signal.aborted, ctx.Err() is signal.reason, context.WithTimeout is a timer that calls abort. What is missing is the convention: Go's community agreed that ctx is the first parameter of anything that blocks, so cancellation composes everywhere. In JavaScript only some APIs accept a signal — fetch does, setTimeout does not — and nothing enforces the checking, so a loop that never looks at it simply runs to completion. Values-on-a-context has no equivalent at all.A worker pool becomes a concurrency limit you write
The jobs-channel-plus-N-workers idiom is so standard in Go that it feels like a language feature. There is no primitive for it here, and the replacement is fifteen lines you will end up writing once and copying forever.
package main
import (
"fmt"
"sync"
)
func main() {
jobs := make(chan int, 6)
results := make(chan int, 6)
var waitGroup sync.WaitGroup
for worker := 0; worker < 3; worker++ {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
for job := range jobs {
results <- job * 10
}
}()
}
for job := 1; job <= 6; job++ {
jobs <- job
}
close(jobs)
waitGroup.Wait()
close(results)
total := 0
for result := range results {
total += result
}
fmt.Println("total:", total)
}(async () => {
const work = async (job) => job * 10;
// No pool primitive. Run at most 'limit' at a time, by hand:
async function mapWithLimit(items, limit, task) {
const results = [];
let next = 0;
const runners = Array.from({ length: limit }, async () => {
while (next < items.length) {
const index = next++;
results[index] = await task(items[index]);
}
});
await Promise.all(runners);
return results;
}
const results = await mapWithLimit([1, 2, 3, 4, 5, 6], 3, work);
console.log("total:", results.reduce((sum, value) => sum + value, 0));
})();The shape above is the idiomatic hand-rolled version: N runner promises pulling from a shared index, which is exactly a worker pool with the channel replaced by a cursor. In practice most teams reach for
p-limit or p-map from npm rather than writing it. Remember what the limit is for: not CPU parallelism, which does not exist here, but bounding how many outbound requests or file handles are in flight at once.if err != nil Against Exceptions
Errors are thrown, not returned
Failure is not in the return type and not in the signature. A function that can fail looks exactly like one that cannot, and the only way to know is the documentation or the source.
package main
import (
"fmt"
"strconv"
)
func parsePort(text string) (int, error) {
port, err := strconv.Atoi(text)
if err != nil {
return 0, fmt.Errorf("bad port %q: %w", text, err)
}
return port, nil
}
func main() {
for _, text := range []string{"8080", "http"} {
port, err := parsePort(text)
if err != nil {
fmt.Println("error:", err)
continue
}
fmt.Println("port", port)
}
}function parsePort(text) {
const port = Number.parseInt(text, 10);
if (Number.isNaN(port)) throw new TypeError(`bad port "${text}"`);
return port;
}
for (const text of ["8080", "http"]) {
try {
console.log("port", parsePort(text));
} catch (error) {
console.log("error:", error.message);
}
}That is the biggest loss coming from Go, and it has three consequences worth internalising. Nothing warns you about an unhandled failure — there is no unused-value lint and no
errcheck. Any expression can throw, including a property access on undefined, so a try block is about a region of code rather than about a specific call. And you can throw anything at all — a string, a number, undefined — so a catch must not assume it received an Error. The nearest thing to if err != nil as a house style is returning { ok, value, error } by convention, which some codebases do and most do not.Wrapping, unwrapping and errors.Is
Error chaining exists on both sides.
%w becomes the cause option (ES2022), and errors.Is becomes instanceof against the cause.package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func load(id int) error {
if id != 1 {
return fmt.Errorf("loading %d: %w", id, ErrNotFound)
}
return nil
}
func main() {
err := load(2)
fmt.Println(err)
fmt.Println("is not-found:", errors.Is(err, ErrNotFound))
}class NotFoundError extends Error {
constructor(message, options) {
super(message, options);
this.name = "NotFoundError";
}
}
function load(id) {
if (id !== 1) {
throw new Error(`loading ${id}`, { cause: new NotFoundError("not found") });
}
}
try {
load(2);
} catch (error) {
console.log(`${error.message}: ${error.cause.message}`);
console.log("is not-found:", error.cause instanceof NotFoundError);
}The mapping is closer than it looks, with two gaps. There is no
errors.As that walks an arbitrarily deep chain — you follow .cause yourself — and instanceof fails across realms, so an error from a worker or an iframe is not instanceof your class; library code often compares error.name instead. Also note that error.cause is not printed by default when an error surfaces uncaught in Node, so a wrapped error can reach a log with its most useful half missing.The trap: a rejection nobody handled
This is the failure mode a Go reader will actually hit, and it has no analogue: an error that is neither returned nor caught, because the promise carrying it was discarded.
package main
import (
"errors"
"fmt"
)
func main() {
// A Go error you ignore is at worst a value you did not read,
// and errcheck will complain about it.
err := errors.New("boom")
fmt.Println("caught:", err)
fmt.Println("nothing crashed")
}(async () => {
const failing = (async () => { throw new Error("boom"); })();
// Attach the handler NOW. Attaching it a tick later is already too late
// in Node: the process reports an unhandled rejection and exits non-zero.
failing.catch((error) => console.log("caught:", error.message));
await failing.catch(() => {});
console.log("nothing crashed");
})();A promise that rejects with nothing attached becomes an unhandled rejection. Node has terminated the process on one since v15; a browser logs to the console and carries on. The half that catches people is the timing: the handler must be attached in the same tick, so
const p = doWork(); await something(); p.catch(...) is already too late. The habit to build is that every promise is either awaited inside a try or given a .catch at the moment it is created — there is no supervisor and no recover to fall back on.panic/recover against throw/catch
A
panic is the last resort in Go and a throw is ordinary control flow in JavaScript — the standard library throws for a bad parse, a failed fetch and a property access on undefined.package main
import "fmt"
func risky() (result string, err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("recovered: %v", recovered)
}
}()
panic("something broke")
}
func main() {
result, err := risky()
fmt.Println(result, err)
fmt.Println("still running")
}function risky() {
throw new Error("something broke");
}
try {
risky();
} catch (error) {
console.log("", `recovered: ${error.message}`);
}
console.log("still running");So
catch is not recover: catching is normal, expected and cheap, and there is no distinction between "recoverable" and "the program is broken". The deferred-closure dance that turns a panic into a returned error has no counterpart because there is no error return to assign to. finally is the closest thing to defer — it runs on every exit path from the block — but it is per-block rather than per-function and does not stack in LIFO order.defer becomes finally
Cleanup that must happen however the function exits is a
defer in Go and a finally block in JavaScript.package main
import "fmt"
func process() {
fmt.Println("open")
defer fmt.Println("close")
fmt.Println("work")
}
func main() {
process()
fmt.Println("after")
}function process() {
console.log("open");
try {
console.log("work");
} finally {
console.log("close");
}
}
process();
console.log("after");Three differences.
finally is tied to a block rather than to the function, so cleanup has to wrap the work rather than sit next to the acquisition. Several defers stack and unwind in LIFO order; nested try/finally blocks are how you get that, and it reads worse each time. And defer's arguments are evaluated immediately while the call is deferred, which has no analogue at all. There is no using-style block and no destructor: FinalizationRegistry exists and the specification says it may never call you.Values, Types & Zero Values
const is not a constant
The keywords line up misleadingly well.
:= is let, and const exists in both — meaning quite different things.package main
import "fmt"
func main() {
const maxRetries = 3
count := 0
count++
numbers := []int{1, 2}
numbers = append(numbers, 3)
fmt.Println(maxRetries, count, numbers)
}const maxRetries = 3;
let count = 0;
count += 1;
const numbers = [1, 2];
numbers.push(3); // legal: the BINDING is const, not the array
console.log(maxRetries, count, numbers.join(","));A Go
const is a compile-time value, restricted to numbers, strings and booleans, and it has no address. A JavaScript const only stops the binding being reassigned: const numbers still accepts push, and there is no compile-time constant concept at all. Use const by default anyway — it is the linter default and the idiomatic choice — but read it as "this name will not be reassigned", never as "this value cannot change". var exists, is function-scoped rather than block-scoped, and should never be written.There are no zero values
Go's zero value is a design commitment: every declared thing is usable immediately, and
var buffer bytes.Buffer works with no constructor. JavaScript has one answer for everything unset, and it is undefined.package main
import "fmt"
type Config struct {
Host string
Port int
Enabled bool
}
func main() {
var config Config
fmt.Printf("%q %d %t\n", config.Host, config.Port, config.Enabled)
var numbers []int
fmt.Println(len(numbers), numbers == nil)
}class Config {}
const config = new Config();
console.log(config.host, config.port, config.enabled); // undefined ×3
const numbers = [];
console.log(numbers.length, numbers === null);So there is no "" for a missing string, no 0 for a missing number and no usable-empty for a missing struct — reading an unset property gives
undefined, and undefined + 1 is NaN rather than 1. That is the sharpest practical difference: a missing field in Go quietly behaves like an empty one, and in JavaScript it quietly poisons arithmetic instead. Default parameter values and ?? are how the ecosystem fills the gap, and they only fire on null/undefined — never on 0 or "".Equality: three operators, none of them ==
Go refuses to compare slices at all and compares comparable structs by field. JavaScript compares objects and arrays by identity and never by contents.
package main
import (
"fmt"
"slices"
)
func main() {
first := []int{1, 2, 3}
second := []int{1, 2, 3}
// fmt.Println(first == second) // uncomment: slices are not comparable
fmt.Println(slices.Equal(first, second))
type Point struct{ X, Y int }
fmt.Println(Point{1, 2} == Point{1, 2})
}const first = [1, 2, 3];
const second = [1, 2, 3];
console.log(JSON.stringify(first) === JSON.stringify(second)); // contents, the hard way
console.log({ x: 1, y: 2 } === { x: 1, y: 2 }); // no value types: false
console.log(first === second); // identity
console.log(0 == "0", 0 === "0"); // == converts; === does notSo two identical arrays are unequal, and the usual workaround is
JSON.stringify on both — which is wrong for key order and for anything JSON cannot represent; a real deep-equal comes from a library or from node:assert. The struct row has no counterpart at all: there is no value type, so {x:1,y:2} === {x:1,y:2} is always false. And ==, the two-character version, converts its operands before comparing; treat it the way you treat unsafe — the one idiomatic use is value != null, which tests for exactly null and undefined.A type switch becomes typeof and instanceof
Type tests exist, as three different mechanisms rather than one construct — with a couple of famously imperfect answers.
package main
import "fmt"
func describe(value any) string {
switch typed := value.(type) {
case string:
return fmt.Sprintf("string of %d", len(typed))
case int:
return "an integer"
case []int:
return fmt.Sprintf("a slice of %d", len(typed))
default:
return "something else"
}
}
func main() {
fmt.Println(describe("hello"))
fmt.Println(describe(42))
fmt.Println(describe([]int{1, 2}))
}function describe(value) {
if (typeof value === "string") return `string of ${value.length}`;
if (Number.isInteger(value)) return "an integer";
if (Array.isArray(value)) return `a slice of ${value.length}`;
return "something else";
}
console.log(describe("hello"));
console.log(describe(42));
console.log(describe([1, 2]));typeof covers the seven primitives; instanceof covers classes and fails across realms, which is why Array.isArray exists. typeof null is "object" and typeof [] is "object", both permanent warts. What is genuinely missing is the binding half: a type switch gives you typed narrowed to the case's type, and here nothing changes about what you may call — nothing stops you calling a string method in the integer branch. TypeScript restores exactly that, and its narrowing understands all three mechanisms above.iota and named types become frozen objects
There is no
iota, no named type over a primitive, and no method set on one. The idiom is a frozen object of string constants.package main
import "fmt"
type Status int
const (
Active Status = iota
Retired
)
func (s Status) String() string {
if s == Active {
return "active"
}
return "retired"
}
func main() {
fmt.Println(Active, Retired)
fmt.Println(Active == 0)
}const Status = Object.freeze({ Active: "active", Retired: "retired" });
console.log(Status.Active, Status.Retired);
console.log(Status.Active === "active");
// Nothing stops a typo: Status.Actve is undefined, not an error,
// and any string may be passed where a "Status" is expected.Strings rather than integers, because they survive
JSON.stringify and read well in a debugger. What is lost is the type: a function taking a "Status" will accept any string at all, and a typo is undefined rather than a compile error. Object.freeze is shallow and, outside strict mode, ignores writes silently rather than throwing. TypeScript's union of string literals ("active" | "retired") restores the checking, which is much of why a Go author writing a library for this ecosystem ends up shipping type declarations.One Number Type
int, int64, uint8 — all of it is one f64
There is one numeric type and it is IEEE 754 double precision. The whole sized-integer family —
int8 through uint64, float32, rune, byte — collapses into it.package main
import (
"fmt"
"math"
)
func main() {
var count int = 7
fmt.Println(count / 2)
fmt.Println(float64(count) / 2)
var small uint8 = 250
fmt.Println(small + 10)
fmt.Println(math.MaxInt32 + 1)
}const count = 7;
console.log(Math.trunc(count / 2));
console.log(count / 2);
console.log(250 + 10); // 260 — nothing is 8 bits wide
console.log(2 ** 31); // no wrap, no overflowDivision always produces a float, so integer division is
Math.trunc(a / b); use Math.floor when you want Go's behaviour for negative numerators, which differ. Nothing wraps, because nothing has a width — uint8 arithmetic is just arithmetic. Integers are exact only to 2⁵³ (Number.MAX_SAFE_INTEGER), past which additions silently round rather than wrapping, which is a quieter failure than Go's. Fixed-width arithmetic is available through the typed arrays (Uint8Array, Int32Array), which are also what you share with a WASM module.int64 does not survive the boundary
This is the row that bites in WASM interop, in JSON, and in any API that hands JavaScript a 64-bit identifier.
package main
import "fmt"
func main() {
var id int64 = 9007199254740993 // 2^53 + 1
fmt.Println(id)
fmt.Println(float64(id) == float64(id+1)) // the double cannot tell them apart
}const id = 9007199254740993n; // the n suffix makes a BigInt
console.log(id.toString());
console.log(Number(id) === Number(id + 1n)); // true: both round to the same double
// console.log(id + 1); // TypeError: cannot mix BigInt and numberA
BigInt is arbitrary-precision and integral, and it deliberately refuses to mix with number in arithmetic — a rare piece of strictness. It also cannot be JSON.stringifyd without a custom replacer, and Math.* will not accept it. The practical rule for an API a JavaScript client consumes: send 64-bit identifiers as strings. A snowflake ID or a database primary key that arrives as a JSON number is silently rounded by JSON.parse before your code ever sees it, and no error is raised anywhere.Floats print differently
Both languages are IEEE 754 at run time, and the second line of each column is the surprise: Go prints 0.3 where JavaScript prints
0.30000000000000004, for the same expression.package main
import "fmt"
func main() {
first, second := 0.1, 0.2
fmt.Println(first + second) // variables: IEEE 754, so 0.30000000000000004
fmt.Println(0.1 + 0.2) // CONSTANTS: exact, evaluated at compile time
fmt.Println(1.0)
fmt.Printf("%.2f\n", 2.0/3.0)
}const first = 0.1, second = 0.2;
console.log(first + second);
console.log(0.1 + 0.2); // no constant folding: the same wrong answer
console.log(1.0); // prints 1 — the .0 is gone
console.log((2 / 3).toFixed(2));Go's untyped constants are arbitrary-precision and folded at compile time, so a literal
0.1 + 0.2 is exactly 0.3 and only becomes a float64 when it is assigned or passed. JavaScript has no constant folding of that kind, so the literal expression gives the same wrong answer as the variables. That difference bites when porting a numeric algorithm: the Go version may be quietly more accurate than the arithmetic it appears to describe. The rest is printing — JavaScript has no float/integer distinction to preserve, so 1.0 prints as 1, and toFixed returns a string and rounds half-away-from-zero where Go's %.2f rounds half-to-even.Strings & Runes
UTF-8 bytes become UTF-16 code units
Both languages have an encoding that leaks into
len, and they leak differently — which is exactly the sort of difference that produces off-by-one bugs when porting.package main
import (
"fmt"
"unicode/utf8"
)
func main() {
word := "naïve"
fmt.Println(len(word), utf8.RuneCountInString(word))
fmt.Println(len("🦀"), utf8.RuneCountInString("🦀"))
for index, letter := range "né" {
fmt.Println(index, string(letter))
}
}const word = "naïve";
console.log(word.length, [...word].length);
console.log("🦀".length, [...("🦀")].length);
let index = 0;
for (const letter of "né") {
console.log(index, letter);
index += letter.length;
}A Go string is UTF-8 bytes:
len("naïve") is 6 and len("🦀") is 4. A JavaScript string is UTF-16 code units: "naïve".length is 5 and "🦀".length is 2. Ranging over a Go string yields runes with byte offsets; for...of yields code points with no index at all, which is why the example counts by hand. There is no rune type and no byte type — for bytes you want a TextEncoder and a Uint8Array. Grapheme clusters need Intl.Segmenter in both worlds.strings.X becomes a method
Everything in
strings is a method on the string itself, which makes chains read left to right instead of inside out.package main
import (
"fmt"
"strings"
)
func main() {
title := " Hello, World "
fmt.Println(strings.TrimSpace(title))
fmt.Println(strings.ToUpper(strings.TrimSpace(title)))
fmt.Println(strings.ReplaceAll(title, "World", "JS"))
fmt.Println(strings.Contains(title, "World"))
fmt.Println(strings.Join(strings.Split("a,b,c", ","), "-"))
}const title = " Hello, World ";
console.log(title.trim());
console.log(title.trim().toUpperCase());
console.log(title.replaceAll("World", "JS"));
console.log(title.includes("World"));
console.log("a,b,c".split(",").join("-"));The names are guessable:
TrimSpace is trim, Contains is includes, HasPrefix is startsWith, Index is indexOf. The one real trap is replace: strings.Replace takes a count and ReplaceAll replaces everything, while JavaScript's replace with a string argument replaces only the first occurrence — use replaceAll. Strings are immutable in both, so there is no strings.Builder; engines optimise += internally and the array-plus-join idiom is the explicit version.Raw strings and templates
A template literal does what a Go raw string and
fmt.Sprintf do together: it spans lines and it interpolates.package main
import "fmt"
func main() {
name := "Ada"
message := "Dear " + name + ",\n Your order has shipped."
fmt.Println(message)
fmt.Println("C:\\path\\to\\file")
}const name = "Ada";
const message = `Dear ${name},
Your order has shipped.`;
console.log(message);
console.log(String.raw`C:\path\to\file`);What it does not do is strip indentation — a multi-line template keeps whatever leading whitespace the source had, which is why
dedent libraries exist and why the example above is left-aligned. String.raw is the tagged-template form that leaves backslashes alone, so it is the closest thing to a Go backquoted string. Tagged templates in general have no Go counterpart at all: sql\`SELECT ...\` passes the pieces and the values to a function, which is how the ecosystem builds safe query and CSS builders.Regex is a literal, and the engine backtracks
A regular expression is a literal with its own syntax — no
regexp.MustCompile, no package-level variable to hold the compiled form.package main
import (
"fmt"
"regexp"
)
var orderPattern = regexp.MustCompile(`order (\d+)`)
func main() {
text := "order 42 shipped"
match := orderPattern.FindStringSubmatch(text)
fmt.Println(match[1])
fmt.Println(regexp.MustCompile(`\d+`).ReplaceAllString(text, "N"))
}const text = "order 42 shipped";
const match = text.match(/order (\d+)/);
console.log(match[1]);
console.log(text.replace(/\d+/g, "N"));
console.log(/^\w+$/u.test("order"));🚨 The engine is the important difference. Go's RE2 guarantees linear time and therefore refuses backreferences and lookaround; JavaScript's backtracking engine offers both and offers no guarantee, so a hostile pattern or input is a genuine denial-of-service risk. Flags go after the closing slash:
g for every match (the opposite default from ReplaceAllString, which is why replace needs it), i, u for correct Unicode handling, s, m, y. A g regex object carries mutable state in lastIndex, so reusing one across test calls gives alternating answers.Slices & Maps
A slice becomes an array, without the header
An array grows in place, so the
numbers = append(numbers, ...) reassignment that Go requires has no counterpart — and neither does the pointer/length/capacity header behind it.package main
import "fmt"
func main() {
numbers := []int{1, 2, 3}
numbers = append(numbers, 4)
fmt.Println(numbers, len(numbers))
fmt.Println("index 10: panics") // fmt.Println(numbers[10]) would panic
fmt.Println(numbers[1:3])
}const numbers = [1, 2, 3];
numbers.push(4); // mutates in place; nothing to reassign
console.log(numbers.join(","), numbers.length);
console.log("index 10:", numbers[10]); // undefined — no panic, no bounds check
console.log(numbers.slice(1, 3).join(","));Three consequences. There is no
cap and nothing to preallocate: new Array(1000) creates a thousand holes, not a thousand slots, and holes behave badly (skipped by forEach, counted by length). Reading past the end gives undefined rather than panicking, so an off-by-one is silent. And slice copies where Go's slicing aliases the same backing array — which removes the whole family of "my subslice mutated the original" bugs, and means the trick of reusing capacity is gone.A map becomes Map, or a plain object
There are two mapping types, and the newer one is what a Go map should become. A plain object
{} was the only choice for years and is still everywhere.package main
import (
"fmt"
"sort"
)
func main() {
ages := map[string]int{"ada": 36}
ages["grace"] = 45
age, found := ages["nobody"]
fmt.Println(age, found)
names := make([]string, 0, len(ages))
for name := range ages {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
fmt.Println(name, ages[name])
}
}const ages = new Map([["ada", 36]]);
ages.set("grace", 45);
console.log(ages.get("nobody"), ages.has("nobody")); // undefined false
for (const [name, age] of [...ages].sort()) {
console.log(name, age);
}The comma-ok idiom splits into two calls:
get returns undefined for a missing key, and has answers the membership question — necessary, because a key whose value is undefined is indistinguishable otherwise. The ordering difference is the one to internalise: Go deliberately randomises map iteration order, while a Map preserves insertion order and a plain object mostly does (integer-like keys sort first). So code that accidentally depends on iteration order will pass here and fail there. A plain object also coerces every key to a string and carries prototype properties, which is why Object.create(null) exists.map, filter and reduce, which Go only just got
The single loop is the idiomatic Go answer, and the chain is the idiomatic JavaScript one. Both are correct in their own language, and the reason is worth knowing.
package main
import "fmt"
func main() {
numbers := []int{1, 2, 3, 4, 5, 6}
total := 0
for _, number := range numbers {
if number%2 == 0 {
total += number * number
}
}
fmt.Println(total)
}const numbers = [1, 2, 3, 4, 5, 6];
const total = numbers
.filter((number) => number % 2 === 0)
.map((number) => number * number)
.reduce((running, number) => running + number, 0);
console.log(total);Go got generics in 1.18 and
slices/maps packages after, but a hand-written loop is still preferred for clarity. JavaScript's chain is universal, and it is eager: each step builds a whole new array, so a three-step chain over a million elements makes two intermediates and does three passes. For a large source that is the reason to write the loop after all — or to use a generator, since array methods have no lazy form. There is no sum: reduce with an explicit initial value covers it, and omitting the initial value on an empty array throws.Sorting compares strings by default
This is the most famous footgun in the standard library, and a Go reader walks straight into it:
sort() with no comparator converts every element to a string and sorts lexicographically.package main
import (
"fmt"
"slices"
)
func main() {
numbers := []int{10, 9, 100}
slices.Sort(numbers)
fmt.Println(numbers)
words := []string{"pear", "apple"}
slices.SortFunc(words, func(left, right string) int {
return len(left) - len(right)
})
fmt.Println(words)
}const numbers = [10, 9, 100];
console.log([...numbers].sort().join(",")); // "10,100,9" — stringified!
console.log([...numbers].sort((a, b) => a - b).join(","));
const words = ["pear", "apple"];
console.log(words.toSorted((left, right) => left.length - right.length).join(","));So
[10, 9, 100].sort() gives [10, 100, 9]. Always pass a comparator for numbers — it returns a negative number, zero or a positive one, exactly the contract slices.SortFunc uses. sort also mutates in place and returns the same array, so a chain silently modifies the original; toSorted() (ES2023) is the non-mutating version, along with toReversed, toSpliced and with. Sorting has been stable since ES2019, where Go's slices.Sort is not (use SortStableFunc).Passing a slice, and passing an array
Go's slice semantics are the subtlest thing in the language: the header is copied, the backing array is not — so a callee can change your elements but cannot change your length.
package main
import "fmt"
func appendOne(numbers []int) {
numbers = append(numbers, 99) // the CALLER does not see this
}
func setFirst(numbers []int) {
numbers[0] = 99 // but it does see this
}
func main() {
numbers := []int{1, 2, 3}
appendOne(numbers)
fmt.Println(numbers)
setFirst(numbers)
fmt.Println(numbers)
}function appendOne(numbers) {
numbers.push(99); // the caller DOES see this
}
function setFirst(numbers) {
numbers[0] = 99; // and this
}
const numbers = [1, 2, 3];
appendOne(numbers);
console.log(numbers.join(","));
setFirst(numbers);
console.log(numbers.join(","));JavaScript has no such split. An array is one object passed by reference, so
push and an index assignment are equally visible to the caller — which is simpler to reason about and removes the "why did my append vanish" question entirely. What comes back in its place is the need to copy defensively when handing an array to code you do not trust: [...numbers] or structuredClone for a deep copy. There is no array-value type to fall back on the way a Go [3]int array copies.Structs & Objects
A struct copies; an object never does
A Go struct is a value: assigning it, passing it and storing it all copy. Every JavaScript object is a reference, and assignment makes a second name for one object.
package main
import "fmt"
type Point struct {
X int
Y int
}
func main() {
first := Point{X: 1, Y: 2}
second := first // a COPY
second.X = 99
fmt.Println(first.X, second.X)
}const first = { x: 1, y: 2 };
const second = first; // the SAME object
second.x = 99;
console.log(first.x, second.x);
const copied = { ...first }; // this is how you copy — shallowly
copied.x = 1;
console.log(first.x, copied.x);So the "should this be a pointer receiver?" question, and the whole discipline of knowing when you are mutating a copy, simply disappears — replaced by its mirror image: you are always mutating the shared one, and copying is the thing you must remember.
{ ...first } is a shallow copy (nested objects are still shared) and structuredClone(first) is a deep one. There is no value type of any kind, so nothing behaves like a Go array or a struct field embedded by value.Methods live inside the type
There are no methods declared outside a type and no receivers. A
class body holds the methods, and this is the implicit receiver.package main
import "fmt"
type Account struct {
Owner string
balance int
}
func NewAccount(owner string) *Account {
return &Account{Owner: owner}
}
func (a *Account) Deposit(amount int) {
a.balance += amount
}
func (a Account) Balance() int {
return a.balance
}
func main() {
account := NewAccount("Ada")
account.Deposit(100)
fmt.Println(account.Owner, account.Balance())
}class Account {
#balance = 0;
constructor(owner) { this.owner = owner; }
deposit(amount) { this.#balance += amount; }
balance() { return this.#balance; }
}
const account = new Account("Ada");
account.deposit(100);
console.log(account.owner, account.balance());The pointer-versus-value receiver distinction has no counterpart — every method can mutate, because there is nothing but references. Private fields exist now, marked
#, and are genuinely private; the lowercase-means-unexported convention has no equivalent, since exports are per-file rather than per-package. Two things to watch: a method pulled off its object loses this (pass () => account.deposit(1) or account.deposit.bind(account)), and there is no way to add a method to a type you do not own without touching its prototype globally.Embedding becomes inheritance or composition
Embedding promotes the inner type's fields and methods onto the outer one without an is-a relationship. JavaScript's nearest tool is
extends, which does create one.package main
import "fmt"
type Timestamps struct {
UpdatedAt string
}
func (t *Timestamps) Touch() {
t.UpdatedAt = "2026-08-18"
}
type Post struct {
Timestamps // embedded: Post gets Touch() and UpdatedAt
Title string
}
func main() {
post := Post{Title: "Hello"}
post.Touch()
fmt.Println(post.Title, post.UpdatedAt)
}class Timestamps {
updatedAt = null;
touch() { this.updatedAt = "2026-08-18"; }
}
class Post extends Timestamps {
constructor(title) { super(); this.title = title; }
}
const post = new Post("Hello");
post.touch();
console.log(post.title, post.updatedAt);The behaviour looks the same and the semantics differ: a
Post genuinely is a Timestamps here, satisfies instanceof, and can only extend one thing. Go's embedding is composition with syntactic promotion, and you can embed several types. The closer analogue for multiple embedding is a mixin — Object.assign(Post.prototype, timestampBehaviour) — which is what libraries do, at the cost of the conflict detection Go gives you (ambiguous promoted names are a compile error; Object.assign silently takes the last one).No constructor convention, and a real constructor
Go has no constructors —
NewX is a convention, and it is the only way to reject bad input, since a zero-value struct is always constructible.package main
import (
"errors"
"fmt"
)
type Percentage struct {
Value int
}
func NewPercentage(value int) (*Percentage, error) {
if value < 0 || value > 100 {
return nil, errors.New("out of range")
}
return &Percentage{Value: value}, nil
}
func main() {
percentage, err := NewPercentage(150)
fmt.Println(percentage)
fmt.Println(err)
}class Percentage {
constructor(value) {
if (value < 0 || value > 100) throw new RangeError("out of range");
this.value = value;
}
static tryFrom(value) {
return value >= 0 && value <= 100 ? new Percentage(value) : null;
}
}
console.log(Percentage.tryFrom(150));
try { new Percentage(150); } catch (error) { console.log(error.message); }JavaScript has a real constructor that runs on every
new, so validation has a place the caller cannot skip. The price is that it can only throw, never return an error value or null — a constructor's return value is ignored unless it is an object. So the (value, error) pair becomes either a throwing constructor or a static factory that returns null, as above. A static factory is also the way to get several named ways of building the same type, which Go gets from having several NewX functions.Property access can run code
Go keeps a strict line between a field access and a method call, and the parentheses tell you which is which. JavaScript lets a property access run arbitrary code.
package main
import "fmt"
type Rectangle struct {
Width float64
Height float64
}
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
func main() {
rectangle := Rectangle{Width: 3, Height: 4}
fmt.Println(rectangle.Area())
}class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
get area() { return this.width * this.height; }
}
const rectangle = new Rectangle(3, 4);
console.log(rectangle.area); // no parentheses: the getter ranA
get accessor means rectangle.area is a function call with no syntactic hint, which is convenient and occasionally alarming — a property read can throw, can be slow, and can have side effects. The same mechanism is available dynamically through Object.defineProperty and, more aggressively, through a Proxy, which can intercept any property access on an object; that is how reactive frameworks like Vue track dependencies. Nothing in Go corresponds to either.Structural Typing, Twice
Both languages are structural — and check at different times
This is the real convergence on the page, and no other Go pairing gets to make it: both languages accept a value because of its shape, not because it declared a relationship.
package main
import "fmt"
type Speaker interface {
Speak() string
}
type Dog struct{}
type Robot struct{}
func (Dog) Speak() string { return "Woof" }
func (Robot) Speak() string { return "Beep" }
func main() {
// No "implements" anywhere: satisfied by shape, checked at COMPILE time.
for _, speaker := range []Speaker{Dog{}, Robot{}} {
fmt.Println(speaker.Speak())
}
}const dog = { speak: () => "Woof" };
const robot = { speak: () => "Beep" };
// Also satisfied by shape — but checked at the moment of the CALL.
for (const speaker of [dog, robot]) {
console.log(speaker.speak());
}Where they part is when. Go checks at compile time, so a missing method is an error at the assignment and the interface is a real type you can name. JavaScript checks at the instant of the call, so a missing method is a
TypeError in production and there is nothing to name — the "interface" lives in your head or in a comment. Two things follow: the small-interface discipline Go teaches (io.Reader, one method) transfers perfectly as a design habit, and TypeScript's interface is genuinely the same idea as Go's, checked at compile time and erased at run time.any is the default, not an escape hatch
Go's
any is deliberate: you write it when you mean it, and you pay for it with a type assertion on the way out. In JavaScript every parameter is any and there is nothing to assert.package main
import "fmt"
func describe(value any) string {
if text, ok := value.(string); ok {
return "string: " + text
}
return fmt.Sprintf("other: %v", value)
}
func main() {
fmt.Println(describe("hello"))
fmt.Println(describe(42))
}function describe(value) {
if (typeof value === "string") return "string: " + value;
return `other: ${value}`;
}
console.log(describe("hello"));
console.log(describe(42));The comma-ok assertion (
value.(string)) becomes a typeof test, and the panicking form (value.(string) without ok) has no counterpart — there is no cast that can fail, because there is no cast. What this changes in practice is where you validate: a Go program can trust a typed parameter and check only at the edges, while a JavaScript library that accepts anything either validates every public entry point or documents the shape and hopes. Runtime schema validators (Zod, Valibot) exist for exactly this, and they are what an encoding/json unmarshal into a struct does for free.Generics have nothing to constrain
Go's generics and type sets exist to let one function work over several types while the compiler still checks each use. JavaScript functions are generic by default, which is not the same thing as being safe.
package main
import "fmt"
type Number interface {
~int | ~float64
}
func Sum[T Number](values []T) T {
var total T
for _, value := range values {
total += value
}
return total
}
func main() {
fmt.Println(Sum([]int{1, 2, 3}))
fmt.Println(Sum([]float64{1.5, 2.5}))
}function sum(values) {
return values.reduce((total, value) => total + value, 0);
}
console.log(sum([1, 2, 3]));
console.log(sum([1.5, 2.5]));
// One function covers both, because there is one number type — and it
// also "covers" strings, arrays and objects, whether you meant it to or not.
console.log(sum(["a", "b"]));The last line is the point:
sum(["a", "b"]) returns "0ab", silently, because + is defined for strings and the initial value is a number. No constraint could have prevented it, because there are no types to constrain. What replaces a type parameter is documentation, a runtime guard, or TypeScript — where the generic syntax is close enough to Go's to feel familiar, including constraints (<T extends number>). If you are porting a generic Go package, the type parameters are usually the part worth preserving in the .d.ts.Compile-time proof, and how to fake it
The
var _ Handler = (*Echo)(nil) line is a small Go idiom with real value: it fails the build the moment the type stops satisfying the interface.package main
import "fmt"
type Handler interface {
Handle(request string) string
}
type Echo struct{}
func (Echo) Handle(request string) string { return "echo: " + request }
// The idiom that asserts satisfaction at compile time:
var _ Handler = (*Echo)(nil)
func main() {
var handler Handler = Echo{}
fmt.Println(handler.Handle("hi"))
}const echo = {
handle(request) { return "echo: " + request; },
};
// No compile-time assertion exists. The runtime version, if you need one:
function assertHandler(candidate) {
if (typeof candidate.handle !== "function") {
throw new TypeError("not a Handler");
}
return candidate;
}
console.log(assertHandler(echo).handle("hi"));There is no equivalent, because there is no build. The nearest thing is a runtime guard as above, which costs a check and fires only when the code path runs — or TypeScript's
satisfies operator, which is the direct analogue and is checked before you ship. This is worth knowing if you are publishing a package: the JavaScript ecosystem's answer to "does this still fit the contract" is a test suite plus type declarations, and there is no third option.Functions & Closures
One return value, and destructuring
A function returns exactly one value. Multiple returns become an array or an object, unpacked at the call site by destructuring.
package main
import "fmt"
func divide(numerator, denominator int) (int, int) {
return numerator / denominator, numerator % denominator
}
func main() {
quotient, remainder := divide(17, 5)
fmt.Println(quotient, remainder)
}function divide(numerator, denominator) {
return [Math.trunc(numerator / denominator), numerator % denominator];
}
const [quotient, remainder] = divide(17, 5);
console.log(quotient, remainder);
// Or, when the parts deserve names:
const divideNamed = (a, b) => ({ quotient: Math.trunc(a / b), remainder: a % b });
const { quotient: q } = divideNamed(17, 5);
console.log(q);The array form is positional and terse, and it is what hooks-style APIs use. The object form gives the parts names, survives reordering, and is what most codebases prefer for more than two values — it is also, not coincidentally, the shape a Go author would reach for a named struct to express. Destructuring works in parameter lists too, and carries default values (
const { port = 5432 } = options), which is the substitute for Go's absent default arguments.Closures, and the loop-variable trap in both
Closures capture the variable itself in both languages, so the counter keeps counting after the enclosing function has returned. This is one of the closest correspondences on the page.
package main
import "fmt"
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
func main() {
counter := makeCounter()
fmt.Println(counter(), counter(), counter())
}function makeCounter() {
let count = 0;
return () => ++count;
}
const counter = makeCounter();
console.log(counter(), counter(), counter());Both languages also had the same famous bug: a closure created in a loop capturing the shared loop variable. JavaScript fixed it by giving
let a fresh binding per iteration (so var in a loop is still wrong and let is right), and Go fixed it in 1.22 by scoping for variables per iteration — before that you wrote index := index. Two things to keep: the C-style for (var ...) form still has the old behaviour, and a closure held by an event handler keeps everything it captured alive, which is the shape of most JavaScript memory leaks.Variadics and spread
Both languages collect extra arguments into a sequence and spread a sequence into a call, with
... moving from after the name to before it.package main
import "fmt"
func total(numbers ...int) int {
sum := 0
for _, number := range numbers {
sum += number
}
return sum
}
func main() {
fmt.Println(total(1, 2, 3))
values := []int{4, 5}
fmt.Println(total(values...))
}function total(...numbers) {
return numbers.reduce((sum, number) => sum + number, 0);
}
console.log(total(1, 2, 3));
const values = [4, 5];
console.log(total(...values));The JavaScript rest parameter gives you a real array with all the array methods, and the spread operator works in array literals (
[...first, ...second]) and object literals ({ ...defaults, ...overrides }) as well as in calls — neither of which Go's ... can do. Arity is never checked in either direction: extra arguments are ignored and missing ones become undefined, where Go would refuse to compile.Functions as values, and as objects
Functions are values in both languages, and there is no named function type to declare — the parameter simply is whatever is passed.
package main
import "fmt"
type Transform func(int) int
func apply(transform Transform, value int) int {
return transform(value)
}
func main() {
double := func(value int) int { return value * 2 }
fmt.Println(apply(double, 21))
}const apply = (transform, value) => transform(value);
const double = (value) => value * 2;
console.log(apply(double, 21));
console.log(double.length, double.name); // arity and name, at run timeA JavaScript function is also an object: it has a
length (declared arity), a name, and you can attach your own properties to it. That is how memoisation caches and framework hooks are sometimes bolted on. Two things Go has that this does not: a declared function type (type Transform func(int) int) that documents the contract and is checked, and methods as first-class values with a bound receiver — account.Deposit in Go is a bound method value, while account.deposit in JavaScript is an unbound function that has lost this.nil, null and undefined
Two empties where Go has one nil
JavaScript has two values for "nothing" and they are not interchangeable:
undefined comes from a missing property, a missing argument or a function with no return, while null is what a programmer writes deliberately.package main
import "fmt"
type Config struct {
Host *string
}
func main() {
var config Config
if config.Host == nil {
fmt.Println("localhost")
}
host := "db.example.com"
config.Host = &host
fmt.Println(*config.Host)
}const config = { host: null };
console.log(config.host ?? "localhost"); // null → fallback
config.host = "db.example.com";
console.log(config.host);
console.log(config.missing ?? "localhost"); // undefined → fallback TOO
console.log(typeof null, typeof undefined);Neither is a zero value, so a missing number is not 0 and a missing string is not "".
?? and ?. treat both alike, which is what you want, and value != null is the idiomatic test for "either" — the one place the loose operator is correct. The habit worth forming: never write undefined yourself. Use null for a deliberate empty and let undefined mean "absent", so the distinction carries information instead of noise.The typed-nil trap has no counterpart
Go's most notorious gotcha: an interface value holding a nil pointer is not
nil, because the interface has a type word and a value word and only one of them is empty.package main
import "fmt"
type Failure struct{}
func (Failure) Error() string { return "failed" }
func mightFail(fail bool) error {
var problem *Failure // a nil POINTER
if fail {
problem = &Failure{}
}
if problem != nil {
return problem
}
return nil // returning problem directly here would be non-nil!
}
func main() {
fmt.Println(mightFail(false) == nil)
fmt.Println(mightFail(true))
}function mightFail(fail) {
return fail ? new Error("failed") : null;
}
console.log(mightFail(false) === null);
console.log(mightFail(true).message);
// There is no interface value with a type and no value, so the whole
// typed-nil category of bug cannot occur.That entire category is gone. There is no boxing of a value into an interface, so
null is null and there is nothing for it to be wrapped in. It is worth naming precisely because a Go author carries a defensive habit — the careful if problem != nil { return problem } dance in the anchor column — that has nothing to defend against here. What replaces it is the opposite worry: null and undefined are two things, so a check for one may miss the other.A nil slice works; an undefined array does not
A nil slice is a fully usable empty slice —
len, range and append all work on it — and a nil map is readable. That design has no equivalent here.package main
import "fmt"
func main() {
var numbers []int // nil, and completely usable
fmt.Println(len(numbers), numbers == nil)
numbers = append(numbers, 1)
fmt.Println(numbers)
var lookup map[string]int // nil, and readable
fmt.Println(lookup["missing"], len(lookup))
}let numbers; // undefined, and NOT usable
console.log(numbers?.length ?? 0, numbers === null);
// numbers.push(1); // TypeError: cannot read properties of undefined
numbers = []; // you must create it
numbers.push(1);
console.log(numbers.join(","));
const lookup = new Map();
console.log(lookup.get("missing"), lookup.size);An unset variable is
undefined, and every operation on it throws. So the Go habit of declaring var numbers []int and appending becomes "initialise it to [] first", and the ?. operator is what saves a read from an unset value. The one thing that transfers is the API advice: return an empty array rather than null or undefined, for exactly the reason Go returns a nil slice — so the caller can loop over it without checking.JSON: Tags Against Whatever Arrived
Unmarshalling validates; JSON.parse does not
This is the row a Go author most needs to internalise before writing a client.
json.Unmarshal checks the shape as it parses; JSON.parse hands back whatever was in the text.package main
import (
"encoding/json"
"fmt"
)
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
}
func main() {
var person Person
err := json.Unmarshal([]byte(`{"name":"Ada","age":"36"}`), &person)
fmt.Println(err)
fmt.Printf("%+v\n", person)
}const person = JSON.parse('{"name":"Ada","age":"36"}');
console.log(person.name, person.age, typeof person.age);
console.log(person.missing); // undefined — no error, no warning
console.log(JSON.stringify(person));A wrong type is an
UnmarshalTypeError in Go and a silent "36" here — a string flowing on into arithmetic to become NaN three functions later. There are no struct tags, no omitempty and no field mapping: the keys are the keys. So validation at the boundary is your job, done by hand or by a schema library (Zod, Valibot, Ajv), and it is the single most valuable habit to carry over. Note also what JSON.stringify silently drops: undefined values, functions and symbols vanish, NaN and Infinity become null, a BigInt throws, and a cycle throws.Every JSON number is a float
The 64-bit problem again, in the place it does the most damage: a JSON payload crossing between a Go service and a JavaScript client.
package main
import (
"encoding/json"
"fmt"
)
func main() {
var typed struct {
ID int64 `json:"id"`
}
json.Unmarshal([]byte(`{"id":9007199254740993}`), &typed)
fmt.Println(typed.ID) // exact
var loose map[string]any
json.Unmarshal([]byte(`{"id":9007199254740993}`), &loose)
fmt.Printf("%T %v\n", loose["id"], loose["id"]) // float64, and rounded
}const parsed = JSON.parse('{"id":9007199254740993}');
console.log(parsed.id); // 9007199254740992 — already wrong
console.log(Number.isSafeInteger(parsed.id));
// The only fix is to send it as a string:
const safe = JSON.parse('{"id":"9007199254740993"}');
console.log(BigInt(safe.id).toString());Go into a typed
int64 field is exact; Go into map[string]any gives you a float64 and the same rounding JavaScript has. JSON.parse has no typed target at all, so the value is rounded before your code runs and no error is raised anywhere — the ID you log is not the ID that was sent. There is no reviver hook that sees the raw text of a number either. The only reliable fix is on the wire: serialise 64-bit identifiers as strings, which is why so many APIs do.Marshalling a struct against serialising an object
Struct tags declare the wire format next to the field. There is no such mechanism, so the wire format is either "whatever the object happens to hold" or a method you write.
package main
import (
"encoding/json"
"fmt"
)
type Order struct {
ID int `json:"id"`
Note string `json:"note,omitempty"`
Internal string `json:"-"`
Tags []string `json:"tags"`
}
func main() {
encoded, _ := json.Marshal(Order{ID: 1, Internal: "secret", Tags: []string{"new"}})
fmt.Println(string(encoded))
}class Order {
constructor(id, tags) {
this.id = id;
this.tags = tags;
this.internal = "secret";
}
// The nearest thing to struct tags: decide the shape yourself.
toJSON() {
return { id: this.id, tags: this.tags };
}
}
console.log(JSON.stringify(new Order(1, ["new"])));toJSON is the hook JSON.stringify consults, and it is the closest thing to MarshalJSON — it decides the shape, which covers renaming, omitting and computing. Without it, every own enumerable property is serialised, including the one you meant to keep private, which is a real leak vector when an internal object reaches a response body. There is no omitempty: an explicit undefined value is dropped and a null is kept, which is the closest accident to it.Modules, go.mod & npm
Packages become files, and there are two module systems
A file is a module and the path in the import is the path — there is no package clause, no directory-equals-package rule, and no distinction between a package name and an import path.
package main
import (
"fmt"
"strings"
)
func main() {
fmt.Println(strings.ToUpper("hello"))
}// geometry.js: export function area(w, h) { return w * h; }
// main.js:
import { area } from "./geometry.js";
console.log(area(3, 4));Everything is private until
exported, which is Capitalised by another means and rather more precise. Two complications Go does not have: there are two module systems in the wild — ESM (import/export, the standard) and CommonJS (require, Node's original) — declared per package in package.json, and interop between them is the most tedious part of Node work. And imports are not free: everything imported is bundled and shipped to the browser, which is why tree-shaking exists and why an unused import costs kilobytes rather than a compile error.go.mod against package.json
The package manager is the familiar half. The unfamiliar halves are that it is only a package manager, and that version resolution works the opposite way round.
package main
import "fmt"
func main() {
// go get github.com/some/pkg@v1.2.3 -> go.mod + go.sum
// go build ./... -> ONE static binary
// go test / go fmt / go vet / go doc -- all built in
// Minimal version selection: the build picks the LOWEST version that
// satisfies every requirement, so it is reproducible by default.
fmt.Println("one tool, one binary, a small tree")
}// npm install lodash -> node_modules/, thousands of packages
// npm run build -> whichever bundler you chose
// test: vitest or jest; lint: eslint; format: prettier; types: tsc
// SemVer ranges with a caret by default, so resolution can change
// tomorrow unless the lockfile is committed and CI runs "npm ci".
console.log("one tool for packages, and a separate one for everything else");npm installs and runs scripts; it does not build, test, format, lint, vet or document — each is a separate dependency you choose and keep in step, which is why a new JavaScript project starts with more decisions than go mod init. Go's minimal version selection makes a build reproducible without a lockfile; npm resolves the highest version matching a caret range, so package-lock.json must be committed and CI must run npm ci. And there is no static binary: the deployable is a directory of source plus a Node runtime, or a bundle — the biggest operational difference when shipping.Tests: a built-in runner against a choice
Go's testing story is one of its strongest selling points, and it is worth knowing exactly which parts do and do not exist here.
package main
import "fmt"
func double(value int) int { return value * 2 }
// In a real package this is double_test.go, in the SAME package:
// func TestDouble(t *testing.T) {
// if got := double(21); got != 42 {
// t.Errorf("double(21) = %d, want 42", got)
// }
// }
// Table-driven tests, t.Run subtests, go test -race, and doc examples
// that are compiled AND run -- all in the standard library.
func main() {
fmt.Println(double(21))
}const assert = require("node:assert");
function double(value) { return value * 2; }
// In a real project this is double.test.js, run by "node --test",
// vitest or jest -- never in the shipped module.
assert.strictEqual(double(21), 42);
console.log(double(21));Node has had a built-in runner since 18 (
node --test with node:assert), which is the closest thing to go test; most projects still use vitest or jest for watch mode, mocking, snapshots and a browser environment. What has no counterpart at all is the doc example — Go compiles and runs the examples in your documentation, and nothing here does. There is no -race either, because there is no race, and no built-in benchmark harness or coverage tool: those are flags on whichever runner you chose.Go in the Browser
GOOS=js GOARCH=wasm, and what it costs
This is the concrete bridge, and the reason a Go author reads this page at all. The build is two environment variables; the size is the part that decides whether it is viable.
package main
import "fmt"
func main() {
// GOOS=js GOARCH=wasm go build -o main.wasm
// cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" .
//
// The output carries the Go RUNTIME -- scheduler, garbage collector,
// the lot -- so a hello-world is ~2MB before compression, ~500KB after.
// TinyGo trades standard-library coverage for a fraction of that.
fmt.Println("one binary, and it brings its runtime with it")
}// index.html:
// <script src="wasm_exec.js"></script>
// <script>
// const go = new Go();
// WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject)
// .then((result) => go.run(result.instance));
// </script>
console.log("the JS side loads a blob and calls go.run");Because the Go runtime ships with the module, size shapes every decision: a trivial program is around 2MB uncompressed, and TinyGo is the usual answer at a fraction of that, with meaningful standard-library gaps (reflection, parts of
net, goroutine scheduling differences). wasm_exec.js is the glue and must come from the same Go version as the build — a mismatched pair fails at instantiation with unhelpful errors. Goroutines do work: the runtime multiplexes them onto the single browser thread, so they are concurrency without parallelism, exactly like promises.syscall/js: calling across the boundary
Interop goes through
syscall/js, the least Go-like package in the standard library: everything is a js.Value, nothing is type-checked, and a wrong .Int() panics.package main
import "fmt"
func main() {
// import "syscall/js"
//
// js.Global().Get("console").Call("log", "from Go")
// js.Global().Set("addNumbers", js.FuncOf(func(this js.Value, args []js.Value) any {
// return args[0].Int() + args[1].Int()
// }))
// select {} // block forever, or main returns and the funcs die
fmt.Println("everything crosses as a js.Value, checked at run time")
}// After go.run(instance) has started the module:
// console.log(addNumbers(2, 3)); // 5, computed in Go
//
// Every value crossing is converted: numbers, strings, booleans and
// []byte survive; structs, maps, channels and errors do NOT.
console.log("call it like any other function");Three practical rules.
js.FuncOf registers a callback and must be Release()d or it leaks, and the program must not return from main while callbacks are live — which is why select {} appears in every example. Only primitives, strings and byte slices cross; a struct becomes a map[string]any you build by hand, or JSON on both sides, which is usually simpler. And each crossing is a real conversion, so the FFI rule applies: cross rarely with large payloads rather than often with small ones. GopherJS is the alternative — it compiles Go to readable JavaScript rather than WASM, with far better interop and a much smaller footprint, at the cost of goroutine fidelity.When to reach for WASM, and when to write JavaScript
The last row is the practical one: most Go code aimed at the JavaScript ecosystem never becomes JavaScript at all.
package main
import "fmt"
func main() {
// Worth compiling to WASM:
// a parser, a codec, a compiler, an image or crypto pipeline --
// anything CPU-bound with a small, coarse interface.
// Not worth it:
// DOM work, fetch orchestration, anything chatty across the boundary.
fmt.Println("cross rarely, with large payloads")
}// The other direction is the one most Go teams actually take:
// esbuild ships as native binaries with a thin npm wrapper that
// downloads the right one per platform.
// The JavaScript world consumes a Go tool happily -- as a BINARY,
// not as a module.
console.log("npm i esbuild -> a Go binary, wrapped");The dominant pattern for a Go tool is to publish platform binaries and a small npm package that downloads the right one — which is how esbuild reaches millions of installs without a line of its logic being ported. WASM earns its place when the code must run in the page, or in an edge runtime with no processes: a parser, a codec, a crypto or image pipeline, anything CPU-bound with a coarse interface. And when the work is DOM manipulation, fetch orchestration or UI state, the honest answer is to write it in JavaScript — the boundary cost of doing it from Go exceeds what the port saves.