Skip to content
gohacks.dev
Basics

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.

13 min read
Summarize in AI
Flat illustration of an editor window full of code, with the JavaScript logo.

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:

JavaScript
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);
Text
[ 1, 10, 9 ]
object 0.30000000000000004 10 [object Object] Infinity
-Infinity true false

Go refuses most of these at compile time, and the rest behave the way arithmetic should:

main.goGo
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)
}
Text
[1 9 10]
0.30000000000000004 +Inf false

The 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:

JavaScript
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]);
Text
[ 'ada', 'linus' ] { name: 'ada', age: 36, active: true }
main.goGo
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])
}
Text
[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:

JavaScript
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();
Text
[ 'a', 'b', 'c' ]
main.goGo
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)
}
Text
[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:

JavaScript
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()));
Text
1 2 3 1
[ 0, 1, 2 ]
main.goGo
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()
}
Text
1 2 3 1
0 1 2

Both 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:

JavaScript
const data = JSON.parse('{"user": {"name": "ada"}}');
console.log(data.user.name, data.user.address?.city ?? "unknown", data.settings?.theme ?? "light");
Text
ada unknown light
main.goGo
package 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)
}
Text
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:

JavaScript
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); }
}
Text
8080
error: port must be a number, got "http"
error: port 70000 out of range
main.goGo
package 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)
	}
}
Text
8080
error: port must be a number, got "http"
error: port 70000 out of range

The 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:

JavaScript
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); }
Text
12
lost this: TypeError
main.goGo
package 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)
}
Text
12
still bound: 13

Both 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:

JavaScript
console.log(2 ** 53, 2 ** 53 + 1, 9007199254740993n, 0.1 * 3, 7 / 2, Math.trunc(7 / 2));
Text
9007199254740992 9007199254740992 9007199254740993n 0.30000000000000004 3.5 3
main.goGo
package 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)
}
Text
9007199254740992 9007199254740993 9223372036854775807 3 3.5
-128

JavaScript 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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.