Go for JavaScript Developers a Practical Translation
A practical translation of JavaScript habits into Go with runnable pairs: array methods, sort, promises, closures, optional chaining, JSON and errors.

JavaScript developers have one advantage when they come to Go and one disadvantage. The advantage: you already write asynchronous, callback-heavy code and understand closures, JSON and web protocols, so the concepts in a Go web service are familiar. The disadvantage: the language is the opposite of JavaScript in several ways that matter, and the habits that make you fast in Node produce Go code that fights the compiler. This guide translates the habits, one runnable pair at a time. The JavaScript ran on Node.js 24 and the Go on 1.25, and the outputs sit under each block. It sits in the Go basics path, and each Go block is a complete program you can run with go run . in a module.
Tested with Go 1.25.6 and Node.js 24, macOS on Apple Silicon (arm64)
The shape of the difference
Five differences cover most of what you will trip on. Go is statically typed, so every value has a type known at compile time, and conversions are explicit. Go is compiled to a native binary, with no interpreter, no node_modules and no runtime to install. Go has no undefined: every variable has a zero value. Go has no exceptions: failure is a return value. And Go runs concurrent code in parallel on all cores through goroutines, with no event loop and no promise chain. Hold those in mind as you read the pairs.
1. The quirks you stop having
JavaScript's coercions are a well-known source of surprises. Here is a handful in Node:
console.log([10, 9, 1].sort());
console.log(typeof null, 0.1 + 0.2, "5" * 2, [] + {}, 1 / 0);
console.log(Math.max(), [1, 2, 3] == "1,2,3", NaN === NaN);[ 1, 10, 9 ]
object 0.30000000000000004 10 [object Object] Infinity
-Infinity true falseGo refuses most of these at compile time, and the rest behave the way arithmetic should:
package main
import (
"fmt"
"slices"
)
func main() {
nums := []int{10, 9, 1}
slices.Sort(nums)
fmt.Println(nums)
a, b, zero := 0.1, 0.2, 0.0
fmt.Println(a+b, 1/zero, zero/zero == zero/zero)
}[1 9 10]
0.30000000000000004 +Inf falseThe first JavaScript line prints [ 1, 10, 9 ], because sort() with no comparison function sorts by string. Go sorts the integers numerically. The second line shows typeof null as 'object', a bug from 1995 that stays for compatibility, and "5" * 2 quietly becoming 10. Go has no equivalent: the string and the integer are different types, and the multiplication does not compile, as the mismatched types error explains. The floating point results are identical, 0.30000000000000004 in both, since both languages use IEEE 754, as the floating point article shows. I wrote the Go sum with variables on purpose: fmt.Println(0.1+0.2) with two constants prints 0.3, because Go evaluates constant expressions exactly at compile time, a difference from JavaScript that untyped constants explains. What Go adds is that a division by an integer zero constant is rejected by the compiler and a runtime integer division by zero panics, while float division gives +Inf like JavaScript's. And NaN is not equal to itself in either.
2. Array methods become loops
The functional chain is the JavaScript idiom, and the Go idiom is a loop:
const users = [
{ name: "ada", age: 36, active: true },
{ name: "kid", age: 9, active: true },
{ name: "grace", age: 45, active: false },
{ name: "linus", age: 28, active: true },
];
const names = users
.filter((u) => u.active && u.age >= 18)
.sort((a, b) => b.age - a.age)
.map((u) => u.name);
console.log(names, users[0]);[ 'ada', 'linus' ] { name: 'ada', age: 36, active: true }package main
import (
"cmp"
"fmt"
"slices"
)
type User struct {
Name string
Age int
Active bool
}
func main() {
users := []User{{"ada", 36, true}, {"kid", 9, true}, {"grace", 45, false}, {"linus", 28, true}}
var adults []User
for _, u := range users {
if u.Active && u.Age >= 18 {
adults = append(adults, u)
}
}
slices.SortFunc(adults, func(a, b User) int { return cmp.Compare(b.Age, a.Age) })
names := make([]string, 0, len(adults))
for _, u := range adults {
names = append(names, u.Name)
}
fmt.Println(names, users[0])
}[ada linus] {ada 36 true}The result is the same two names in the same order. The object literal became a struct type declared once, which gives you a compile error for a misspelled field, where JavaScript gives undefined. The chain became a filter loop, a sort and a map loop. It is longer and no slower, and the for loop can break, continue and return in a way a callback cannot. The sort call mutates in place in both languages, a surprise that carries over, and the comparison function in Go returns an int, negative, zero or positive, via cmp.Compare, where JavaScript's subtraction trick works only for numbers that cannot overflow. Slices explained and sorting with the slices package cover the mechanics.
3. Promises become goroutines
This is the largest conceptual shift, and the one that most pleases people once it lands. In JavaScript, I/O does not block because of promises and the event loop. In Go, I/O blocks the goroutine and the runtime runs another one:
const delay = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
async function main() {
const results = await Promise.all([delay(30, "a"), delay(10, "b"), delay(20, "c")]);
console.log(results);
}
main();[ 'a', 'b', 'c' ]package main
import (
"fmt"
"sync"
"time"
)
func delay(ms int, value string) string {
time.Sleep(time.Duration(ms) * time.Millisecond)
return value
}
func main() {
inputs := []struct {
ms int
value string
}{{30, "a"}, {10, "b"}, {20, "c"}}
results := make([]string, len(inputs))
var wg sync.WaitGroup
for i, in := range inputs {
wg.Add(1)
go func() {
defer wg.Done()
results[i] = delay(in.ms, in.value)
}()
}
wg.Wait()
fmt.Println(results)
}[a b c]Both programs print [a b c] in the input order, though the tasks finish in the order b, c, a. Promise.all keeps order by position, and the Go version does it by writing each result into its own slot. Notice what is missing: there is no async keyword, no await, and delay is an ordinary blocking function that time.Sleeps. A goroutine costs a few kilobytes, so starting one per task is normal, and the scheduler multiplexes them over the CPUs, as the scheduler article describes. The consequence that surprises Node developers is that Go functions are not colored. A function that blocks and a function that does not look the same, and any function can call any other, so there is no rewriting a call chain when something deep inside starts doing I/O.
The other consequence is parallelism. Node runs your JavaScript on one thread, and a long computation blocks every request. Go goroutines run on all cores at once, so CPU-bound work in one goroutine does not stall the others, and you pay for it with the data race risks that a single-threaded model avoids. Go concurrency patterns that survive production is where this leads, and the memory model is the rule that keeps shared data safe.
4. Closures, almost the same
Closures work as you expect, including the capture rules, with a historic difference that has now been fixed:
function counter() {
let n = 0;
return () => ++n;
}
const a = counter(), b = counter();
console.log(a(), a(), a(), b());
const fns = [];
for (let i = 0; i < 3; i++) fns.push(() => i);
console.log(fns.map((f) => f()));1 2 3 1
[ 0, 1, 2 ]package main
import "fmt"
func counter() func() int {
n := 0
return func() int {
n++
return n
}
}
func main() {
a, b := counter(), counter()
fmt.Println(a(), a(), a(), b())
var fns []func() int
for i := 0; i < 3; i++ {
fns = append(fns, func() int { return i })
}
for _, f := range fns {
fmt.Print(f(), " ")
}
fmt.Println()
}1 2 3 1
0 1 2Both print 1 2 3 1 and 0 1 2. JavaScript's let has given each loop iteration its own binding since ES2015, which is the behavior Go adopted in 1.22, as the loop variable change explains. If you read older Go code or work in a module that still says go 1.21, the Go loop shares one variable and prints 3 3 3, the same trap var had in JavaScript. Closures in Go has the details, and the this problem that closures were invented to dodge does not exist: a method has an explicit receiver, as pointers and receivers explains.
5. null, undefined and optional chaining
JavaScript has two empty values and the ?. and ?? operators to cope with them. Go has one nil, only for pointers, maps, slices, channels, functions and interfaces, and zero values for everything else. Reading nested JSON with missing parts shows the contrast:
const data = JSON.parse('{"user": {"name": "ada"}}');
console.log(data.user.name, data.user.address?.city ?? "unknown", data.settings?.theme ?? "light");ada unknown lightpackage main
import (
"encoding/json"
"fmt"
)
type Address struct {
City string `json:"city"`
}
type User struct {
Name string `json:"name"`
Address *Address `json:"address"`
}
type Settings struct {
Theme string `json:"theme"`
}
type Doc struct {
User User `json:"user"`
Settings *Settings `json:"settings"`
}
func (u User) City() string {
if u.Address == nil || u.Address.City == "" {
return "unknown"
}
return u.Address.City
}
func (d Doc) Theme() string {
if d.Settings == nil || d.Settings.Theme == "" {
return "light"
}
return d.Settings.Theme
}
func main() {
var doc Doc
err := json.Unmarshal([]byte(`{"user": {"name": "ada"}}`), &doc)
fmt.Println(doc.User.Name, doc.User.City(), doc.Theme(), err)
}ada unknown light <nil>The answers are the same, ada unknown light. The mechanism is not. Go decodes into a typed struct, so a missing field is the zero value, and a missing object is a nil pointer, which you check once in a method that gives the default. There is no ?. because there is no automatic traversal through possibly missing values: a nil dereference is a panic, so you write the check where it matters. The upside is that the shape of the document is written down in the types, and a misspelled field is a compile error, where in JavaScript data.user.adress?.city quietly returns undefined forever. For JSON you do not control, decode to map[string]any and use type assertions, as the type switch article shows.
6. Errors instead of throw
JavaScript throws, and async code rejects. Go returns:
function parsePort(text) {
const port = Number(text);
if (!Number.isInteger(port)) throw new Error(`port must be a number, got "${text}"`);
if (port < 1 || port > 65535) throw new RangeError(`port ${port} out of range`);
return port;
}
for (const v of ["8080", "http", "70000"]) {
try { console.log(parsePort(v)); } catch (e) { console.log("error:", e.message); }
}8080
error: port must be a number, got "http"
error: port 70000 out of rangepackage main
import (
"fmt"
"strconv"
)
func parsePort(text string) (int, error) {
port, err := strconv.Atoi(text)
if err != nil {
return 0, fmt.Errorf("port must be a number, got %q", text)
}
if port < 1 || port > 65535 {
return 0, fmt.Errorf("port %d out of range", port)
}
return port, nil
}
func main() {
for _, v := range []string{"8080", "http", "70000"} {
port, err := parsePort(v)
if err != nil {
fmt.Println("error:", err)
continue
}
fmt.Println(port)
}
}8080
error: port must be a number, got "http"
error: port 70000 out of rangeThe outputs match. The JavaScript function's failure is invisible in its signature, and a caller that forgets try crashes the process with an unhandled exception. Go's failure is in the signature, (int, error), and a caller that forgets to look at err compiles and continues with a zero value, which a linter catches. Neither is safer in the abstract, and Go's explicitness is why the code reads straight down, as error handling in Go develops. An unhandled Promise rejection has no Go equivalent: a goroutine that panics ends the whole process, so you recover at goroutine boundaries on purpose.
7. Classes and this
A JavaScript class bundles data and methods, and this refers to the instance, with the well-known problem of losing this when a method is passed as a callback. Go separates the data from the methods and makes the receiver an explicit parameter:
class Counter {
constructor(start) { this.n = start; }
inc() { this.n++; return this; }
}
const c = new Counter(10);
c.inc().inc();
console.log(c.n);
const detached = c.inc;
try { detached(); } catch (e) { console.log("lost this:", e.constructor.name); }12
lost this: TypeErrorpackage main
import "fmt"
type Counter struct{ n int }
func NewCounter(start int) *Counter { return &Counter{n: start} }
func (c *Counter) Inc() *Counter {
c.n++
return c
}
func main() {
c := NewCounter(10)
c.Inc().Inc()
fmt.Println(c.n)
detached := c.Inc
detached()
fmt.Println("still bound:", c.n)
}12
still bound: 13Both print 12. The JavaScript method, detached from its object, loses this and throws a TypeError. The Go method value c.Inc captured the receiver c when it was written, so calling detached() still increments the same counter and the value is 13. That is the receiver being a real argument and not a magic binding, and it is why Go has no bind, no arrow-function workaround and no confusion about which object a callback refers to. The constructor is an ordinary function, NewCounter, following the constructor conventions, and the choice of pointer or value receiver is the question pointer or value receiver answers. There is no inheritance. Where you would write class B extends A, Go has embedding, and embedding is not inheritance. A last difference worth stating: JavaScript classes sit on a prototype chain that any code can modify at run time, and Go's types are fixed at compile time, so there is no monkey patching and no method added to a type from outside its package.
8. Numbers are not all doubles
In JavaScript every Number is an IEEE double, and integers are exact only up to 2 to the power of 53. Go has a family of integer types with their own ranges, and the difference shows at the edges:
console.log(2 ** 53, 2 ** 53 + 1, 9007199254740993n, 0.1 * 3, 7 / 2, Math.trunc(7 / 2));9007199254740992 9007199254740992 9007199254740993n 0.30000000000000004 3.5 3package main
import (
"fmt"
"math"
)
func main() {
var big int64 = 1 << 53
fmt.Println(big, big+1, int64(math.MaxInt64), 7/2, 7.0/2)
var small int8 = 127
small++
fmt.Println(small)
}9007199254740992 9007199254740993 9223372036854775807 3 3.5
-128JavaScript prints 9007199254740992 for both 2 ** 53 and 2 ** 53 + 1, because the second value is not representable, and needs a BigInt literal like 9007199254740993n to be exact. Go's int64 holds both exactly and goes to 9,223,372,036,854,775,807. Integer division in Go truncates, 7/2 is 3, where JavaScript gives 3.5 and needs Math.trunc. And the sized types wrap silently, as the int8 shows, which JavaScript has no analogue for. If you are used to parsing IDs from JSON in JavaScript and watching large ones lose digits, that problem disappears in Go for int64, and it appears in the other direction when a JavaScript client sends a 64-bit ID as a number. Integer types and overflow covers the ranges.
9. If you use TypeScript
TypeScript developers find Go less alien than JavaScript developers do, because the instincts match more. TypeScript interfaces are structural: an object satisfies an interface by having the right shape, and Go interfaces work the same way, as interfaces in Go shows. Type inference, union-like patterns through interfaces and generics all have analogues. The differences to expect: no union types or literal types, so enums are named constants as enums done properly builds; no optional properties, so optional fields are pointers or zero values; no any culture, since any exists and is used rarely; and types are checked at compile time and also exist at run time, so a type assertion can fail. There is no structural typing of struct values: two structs with identical fields are different types unless you convert them explicitly.
Tooling, packages and shipping
The ecosystem difference is the one you feel daily. There is no npm, no package.json and no node_modules of thousands of directories. A module has a go.mod with exact versions and a go.sum of checksums, the standard library covers HTTP servers, JSON, testing, templates and cryptography, and the first dependency you add is usually a router or a database driver, after you have found the standard library's version insufficient. The build output is one static binary, so deployment is copying a file or building a container from scratch. The tools ship with the compiler: gofmt ends the Prettier discussion, go vet replaces the common ESLint rules, and go test runs tests with no framework, as your first program and gofmt set up.
What to carry over and what to leave
Carry over your sense for async design: independent work in parallel, results collected in order, backpressure on queues. Carry over your testing discipline and your comfort with JSON. Leave behind the habit of passing options objects with optional fields, since functional options or a plain struct are the Go forms, and the habit of reaching for a package for everything, since a little copying is better than a little dependency. JavaScript remains the right tool for the browser, quick scripts that touch the DOM and anything built around its ecosystem, and Go is a strong choice for the services behind it. The Tour of Go is the interactive companion, and the Go FAQ answers most of the "why is it like this" questions this article raises.


