JS → GO

A phrasebook for JavaScript developers

JavaScript
→Go

Look up something you already write every day, and read the Go sentence that means the same thing — plus a note on where the translation stops being literal.

→

Fundamentals

Getting a program to run

package main
JavaScript
// hello.js — run: node hello.js

console.log("Hello, world");

// any file can be the entry point,
// and top-level code just runs
→
Go
// hello.go — run: go run hello.go

package main

import "fmt"

func main() {
	fmt.Println("Hello, world")
}

Every Go file belongs to a package. Only package main with a func main() builds into a runnable program. There is no top-level code — statements live inside functions.

Declaring variables

:= and var
JavaScript
let count = 1;
count = 2;

const name = "ada";

let empty;        // undefined
var old = true;   // avoid
→
Go
count := 1        // inside a function
count = 2

const name = "ada"

var empty string  // "" — the zero value
var n int         // 0
var ok bool       // false

:= declares and infers the type in one step, but only inside a function; at package level use var. There is no undefined — every variable starts at its type's zero value. A declared local you never read is a compile error, not a warning.

Types and conversion

strconv
JavaScript
typeof 42;          // "number"

Number("42");       // 42
String(42);         // "42"
parseInt("42", 10); // 42

(42).toFixed(2);    // "42.00"
1 / 2;              // 0.5
→
Go
reflect.TypeOf(42)         // int

n, err := strconv.Atoi("42")
s := strconv.Itoa(42)
f, err := strconv.ParseFloat("4.2", 64)

fmt.Sprintf("%.2f", 42.0)  // "42.00"
1 / 2                      // 0 — integer division
float64(1) / 2             // 0.5

One number becomes int, int64, float64 and friends. Conversion is always explicit — float64(i), int(f) — and never happens behind your back, so mixing an int and a float64 in one expression will not compile. int(2.9) truncates to 2. Parsing returns a value and an error.

Equality and truthiness

no coercion
JavaScript
1 == "1";        // true
1 === "1";       // false

if (list.length) {}
if (str) {}
if (!user) {}

value ?? fallback
user?.address?.city
→
Go
// == is always strict.
// comparing different types won't compile.

if len(list) > 0 { }
if str != "" { }
if user == nil { }

// no ?? and no ?. — check it
if v, ok := m[k]; ok { use(v) }
if u != nil && u.Address != nil { }

There is no truthiness: if takes a bool and nothing else, so every check is spelled out. Structs and arrays compare with == field by field; slices, maps and functions can only be compared against nil, and reflect.DeepEqual or slices.Equal handles the rest.

Branches and loops

one keyword: for
JavaScript
if (x > 1) {} else if (y) {} else {}

for (let i = 0; i < 10; i++) {}
while (ok) {}
do {} while (ok);

for (const x of xs) {}
for (const k in obj) {}

const size = x > 1 ? "big" : "small";
→
Go
if x > 1 { } else if y { } else { }
if v, err := f(); err != nil { }  // scoped init

for i := 0; i < 10; i++ { }
for ok { }                        // while
for { break }                     // infinite

for i, x := range xs { }
for k, v := range m { }

// no ternary — write the if

for is the only loop keyword and covers all four shapes. Conditions take no parentheses, braces are never optional, and the opening brace must sit on the same line. The scoped init form — if v, err := f(); err != nil — keeps v and err from leaking into the surrounding scope.

switch

no break needed
JavaScript
switch (x) {
  case 1:
    one();
    break;
  case 2:
  case 3:
    few();
    break;
  default:
    other();
}
→
Go
switch x {
case 1:
	one()
case 2, 3:
	few()
default:
	other()
}

switch {              // no subject:
case x > 10:          // an if/else chain
	big()
}

Cases break on their own; falling through is opt-in with the fallthrough keyword. One case can list several values, a case can carry an init statement, and a subjectless switch is the idiomatic replacement for a long if/else ladder.

Closures and loop variables

captured by reference
JavaScript
function counter() {
  let n = 0;
  return () => ++n;
}
const next = counter();
next(); // 1
next(); // 2

for (var i = 0; i < 3; i++)
  setTimeout(() => log(i)); // 3 3 3
for (let i = 0; i < 3; i++)
  setTimeout(() => log(i)); // 0 1 2
→
Go
func counter() func() int {
	n := 0
	return func() int { n++; return n }
}
next := counter()
next() // 1
next() // 2

for i := 0; i < 3; i++ {
	go func() { fmt.Println(i) }()
}
// 0 1 2 (in some order) — since Go 1.22
// each iteration gets a fresh i, like let

Closures work exactly as you expect: they capture variables, not values. Since Go 1.22 every loop iteration gets its own copy of the loop variable, matching let. In older code you will still see i := i inside the loop body or func(i int) passed as an argument — that is the pre-1.22 fix for the var-style sharing bug.

Data

Working with strings

strings, fmt
JavaScript
const who = "world";
`Hello, ${who}!`;

"abc".toUpperCase();
"a,b".split(",");
["a", "b"].join("-");
"abc".includes("b");
"abc".trim().replace("a", "z");

"héllo".length;   // 5
→
Go
who := "world"
fmt.Sprintf("Hello, %s!", who)

strings.ToUpper("abc")
strings.Split("a,b", ",")
strings.Join([]string{"a", "b"}, "-")
strings.Contains("abc", "b")
strings.ReplaceAll(strings.TrimSpace(s), "a", "z")

len("héllo")                    // 6 bytes
utf8.RuneCountInString("héllo") // 5

Strings are immutable UTF-8 bytes. len counts bytes; ranging over a string yields runes (code points) with their byte offsets. There are no methods on strings — the verbs live in the strings package. Building a string in a loop wants strings.Builder, not +=.

Numbers and math

math, math/rand/v2
JavaScript
Math.max(1, 2);
Math.abs(-4.2);
Math.floor(1.9);
Math.round(2.5);

Math.random();                 // 0..1
Math.floor(Math.random() * 10);

Number.MAX_SAFE_INTEGER;
10n ** 30n;                    // BigInt
→
Go
max(1, 2)            // built-in since 1.21
math.Abs(-4.2)       // float64 only
math.Floor(1.9)
math.Round(2.5)

rand.Float64()       // math/rand/v2
rand.IntN(10)        // 0..9, no floor dance

math.MaxInt64
new(big.Int).Exp(ten, thirty, nil)

min and max are built-ins; nearly everything else in math takes and returns float64, so integer work means converting in and out. Integers are real integers — no 0.1 + 0.2 class of surprise until you opt into floats, but overflow silently wraps instead of going to Infinity. math/big is the BigInt.

Lists

slices
JavaScript
const xs = [1, 2, 3];

xs.push(4);
xs.length;
xs[0];
xs[1] = 9;

xs.slice(1, 3);
const ys = [...xs];
xs.concat(ys);
→
Go
xs := []int{1, 2, 3}

xs = append(xs, 4)   // reassign!
len(xs)
xs[0]
xs[1] = 9

xs[1:3]              // a view, shares memory
ys := slices.Clone(xs)
append(xs, ys...)

append returns a new slice header, so you must assign the result back. Slicing does not copy: xs[1:3] looks into the same underlying array, and writing through it changes the original — slices.Clone when you need a real copy. [3]int is a fixed-size array and a genuinely different type from the slice []int.

map, filter, reduce

write the loop
JavaScript
xs.map(x => x * 2);

xs.filter(x => x > 1);

xs.reduce((a, b) => a + b, 0);

xs.includes(2);
xs.indexOf(2);
→
Go
out := make([]int, 0, len(xs))
for _, x := range xs {
	out = append(out, x*2)
}

sum := 0
for _, x := range xs {
	sum += x
}

slices.Contains(xs, 2)
slices.Index(xs, 2)

There is no built-in map/filter/reduce, and idiomatic Go writes the loop rather than reaching for a helper. What generics did add is the slices and maps packages, which cover searching, comparing, cloning and sorting.

Sorting

slices.SortFunc
JavaScript
xs.sort((a, b) => a - b);   // mutates
xs.toSorted();              // copies

["b", "a"].sort();

users.sort((a, b) =>
  a.age - b.age ||
  a.name.localeCompare(b.name));
→
Go
slices.Sort(xs)             // mutates
ys := slices.Clone(xs)      // then sort ys
slices.Sort([]string{"b", "a"})

slices.SortFunc(users, func(a, b User) int {
	if c := cmp.Compare(a.Age, b.Age); c != 0 {
		return c
	}
	return cmp.Compare(a.Name, b.Name)
})

The comparator returns negative, zero or positive like JS, and cmp.Compare writes that for you. slices.Sort handles anything ordered without a comparator — and unlike JS it sorts numbers as numbers, no [10, 9, 1] → [1, 10, 9] footgun. Sorting always mutates; clone first for a toSorted.

Key–value lookup

map[K]V
JavaScript
const ages = { ada: 36 };

ages.ada;
ages["ada"];
ages.grace = 45;
delete ages.ada;
"ada" in ages;
Object.keys(ages);

const m = new Map();
m.set("k", 1);
→
Go
ages := map[string]int{"ada": 36}

ages["ada"]
v, ok := ages["ada"]  // ok reports presence
ages["grace"] = 45
delete(ages, "ada")
len(ages)

m := make(map[string]int)
m["k"] = 1            // nil map would panic

Reading a missing key returns the zero value, not undefined, so ages["nobody"] is 0. The comma-ok form is how you tell "absent" from "present and zero". Iteration order is deliberately randomised, and a nil map is fine to read but panics on write — make it first.

Sets

map[T]struct{}
JavaScript
const seen = new Set([1, 2]);

seen.add(3);
seen.has(2);      // true
seen.delete(1);
seen.size;

[...seen];
new Set(xs);      // dedupe an array
→
Go
seen := map[int]struct{}{1: {}, 2: {}}

seen[3] = struct{}{}
_, ok := seen[2]  // true
delete(seen, 1)
len(seen)

slices.Collect(maps.Keys(seen))
// dedupe: range xs, insert each key

There is no set type. The idiom is a map whose values are struct{} — the empty struct occupies zero bytes, so you pay only for keys. map[T]bool works too and reads more nicely (if seen[x]), at the cost of a byte per entry; both are everywhere in real code.

Generators and iterators

range over func
JavaScript
function* nums() {
  yield 1;
  yield 2;
}

for (const n of nums()) {
  use(n);
  if (n > 1) break;
}
→
Go
func nums(yield func(int) bool) {
	if !yield(1) {
		return  // consumer broke out
	}
	yield(2)
}

for n := range nums {  // Go 1.23+
	use(n)
	if n > 1 { break }
}

Since Go 1.23 you can range over a function: the loop body becomes the yield callback, and yield returning false is the break arriving. The iter package names the shapes (iter.Seq[T], iter.Seq2[K, V]), and the standard library now hands them out — maps.Keys, slices.Values, strings.Lines. Before 1.23 people used channels or callbacks for this; channels for iteration are now considered a smell.

Functions and types

Functions

multiple returns
JavaScript
function add(a, b) { return a + b; }
const add2 = (a, b) => a + b;

function greet(name = "you") {}
function sum(...nums) {}

const { a, b } = pair();

const twice = f => x => f(f(x));
→
Go
func add(a, b int) int { return a + b }
add2 := func(a, b int) int { return a + b }

// no default arguments — use an options
// struct or a second constructor
func sum(nums ...int) int { ... }

a, b := pair()   // two return values

func twice(f func(int) int) func(int) int { ... }

No default or named parameters; the usual answers are a config struct or variadic option functions. Returning several values is native, and that is how errors travel. defer f.Close() schedules cleanup to run when the function exits, whichever way it exits.

Objects with a shape

struct
JavaScript
const user = {
  name: "ada",
  age: 36,
};

user.name;

JSON.stringify(user);
const back = JSON.parse(text);
→
Go
type User struct {
	Name string `json:"name"`
	Age  int    `json:"age"`
}

u := User{Name: "ada", Age: 36}
u.Name

b, err := json.Marshal(u)
err = json.Unmarshal(text, &back)

The struct is the everyday object: fixed fields, fixed types, checked at compile time. Tags in backticks control JSON names. A field is visible outside its package — and to encoding/json — only if it starts with a capital letter, which is the single most common reason a struct marshals to {}.

Copies and pointers

& and *
JavaScript
// the engine decides for you:
// objects by reference, primitives by value

function rename(u) { u.name = "new"; }
rename(user);      // user is changed

let n = 1;
function inc(x) { x++; }
inc(n);            // n is still 1
→
Go
// everything is passed by value.
// you choose when to pass an address.

func rename(u *User) { u.Name = "new" }
rename(&u)         // u is changed

func inc(x *int) { *x++ }
inc(&n)            // n is now 2

p := &u
p.Name = "ada"     // auto-dereferenced

A struct argument is a copy unless you pass a pointer, which you do when the function must mutate the original or the value is large. Slices, maps and channels already hold an internal reference, so they behave the way you expect. There is no pointer arithmetic and there is a garbage collector: this is only & and *.

Classes

methods and embedding
JavaScript
class Animal {
  constructor(name) { this.name = name; }
  speak() { return `${this.name} makes a sound`; }
}

class Dog extends Animal {
  speak() { return `${this.name} barks`; }
}

new Dog("rex").speak();
→
Go
type Animal struct{ Name string }

func NewAnimal(name string) *Animal {
	return &Animal{Name: name}
}

func (a *Animal) Speak() string {
	return a.Name + " makes a sound"
}

type Dog struct{ Animal }  // embedding

func (d *Dog) Speak() string {
	return d.Name + " barks"
}

No classes, no new keyword for your own types, no inheritance. A method is a function with a receiver, and the receiver replaces this — you name it yourself, and it is a pointer whenever the method mutates. Embedding promotes the inner type's fields and methods onto the outer one: composition, not a parent. A constructor is just a function called NewDog.

Duck typing

implicit interfaces
JavaScript
// anything with .speak() will do,
// and you find out at runtime

function speakAll(items) {
  for (const i of items) {
    console.log(i.speak());
  }
}

if (x instanceof Dog) {}
→
Go
type Speaker interface {
	Speak() string
}

func speakAll(items []Speaker) {
	for _, i := range items {
		fmt.Println(i.Speak())
	}
}

if d, ok := x.(*Dog); ok { use(d) }

There is no implements. A type satisfies an interface simply by having the methods, which means you can write an interface for someone else's type after the fact. Keep them small — io.Reader is one method. any holds anything, and you get the concrete type back with an assertion or a switch v := x.(type).

Generics

type parameters
JavaScript
// TypeScript
function first<T>(xs: T[]): T {
  return xs[0];
}
first([1, 2]);        // T inferred

interface Box<T> { value: T }

function largest<T extends Ordered>(
  a: T, b: T): T {}
→
Go
func First[T any](xs []T) T {
	return xs[0]
}
First([]int{1, 2})    // T inferred

type Box[T any] struct{ Value T }

func Largest[T cmp.Ordered](a, b T) T {
	if a > b { return a }
	return b
}

Square brackets instead of angle brackets, and a constraint where TS says extends: any accepts everything, comparable allows ==, cmp.Ordered allows <, and a union like int | float64 writes your own. Methods cannot add their own type parameters, and the culture still reaches for a concrete type first — generics are for containers and algorithms, not for everything.

Enums

const and iota
JavaScript
// TypeScript
enum Color { Red, Green, Blue }

// or the object-as-enum idiom
const Color = {
  Red: "red",
  Green: "green",
} as const;

let c: Color = Color.Red;
→
Go
type Color int

const (
	Red Color = iota  // 0
	Green             // 1
	Blue              // 2
)

func (c Color) String() string {
	return [...]string{"red", "green", "blue"}[c]
}

var c Color = Red

There is no enum keyword: the idiom is a named type plus a const block, where iota counts up from zero one line at a time. A String() method makes it print nicely (the stringer tool generates these). Nothing checks exhaustiveness in a switch — linters do — and nothing stops Color(42), so validate at the edges.

null, undefined and nothing

one nil, many zeros
JavaScript
let a;              // undefined
let b = null;       // deliberate nothing

user.middleName ?? "";

function f(opt) {
  if (opt === undefined) opt = {};
}
→
Go
var s string        // "" — just the zero value
var p *User         // nil — no User yet

if p == nil { }

// an "optional" field is a pointer
type Profile struct {
	MiddleName *string `json:"middleName"`
}
if pr.MiddleName != nil { use(*pr.MiddleName) }

Go has one nil, not two flavours of nothing, and most of the time you do not even need it: the zero value ("", 0, false) is designed to be useful, so "absent" and "empty" are usually allowed to mean the same thing. When the difference truly matters — a JSON field that may be omitted — the idiom is a pointer, and nil-checking it is your ?..

When things go wrong

Things going wrong

errors are values
JavaScript
try {
  const data = risky();
  use(data);
} catch (err) {
  console.error(err);
  throw new Error("loading failed");
} finally {
  cleanup();
}
→
Go
defer cleanup()

data, err := risky()
if err != nil {
	return fmt.Errorf("loading: %w", err)
}
use(data)

panic("unrecoverable")   // rare, not control flow

Nothing is thrown. An error is an ordinary value returned next to the result, and the if err != nil block you write every few lines is the idiom, not a sign you are doing it wrong. %w wraps an error with context — the equivalent of re-throwing with a cause — so callers can still see what is underneath.

Making your own errors

errors.Is, errors.As
JavaScript
class NotFoundError extends Error {}

class ValidationError extends Error {
  constructor(field) {
    super(`invalid ${field}`);
    this.field = field;
  }
}

if (err instanceof NotFoundError) {}
if (err instanceof ValidationError) {
  use(err.field);
}
err.cause;
→
Go
var ErrNotFound = errors.New("not found")

type ValidationError struct{ Field string }

func (e *ValidationError) Error() string {
	return "invalid " + e.Field
}

if errors.Is(err, ErrNotFound) { }

var ve *ValidationError
if errors.As(err, &ve) {
	use(ve.Field)
}

Anything with an Error() string method is an error. Two idioms replace subclassing: a package-level sentinel (ErrNotFound) when callers only need identity, and a struct type when the error carries data. errors.Is asks "is this that sentinel, anywhere in the wrap chain?"; errors.As is instanceof plus the cast, fishing the typed error out of the chain.

Concurrency

Doing things at the same time

goroutines
JavaScript
async function load() {
  const a = await fetchA();
  return a;
}

const [x, y] = await Promise.all([p1(), p2()]);

setTimeout(fn, 1000);
→
Go
ch := make(chan string)
go func() { ch <- fetchA() }()
a := <-ch          // blocks until ready

var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); p1() }()
go func() { defer wg.Done(); p2() }()
wg.Wait()

time.AfterFunc(time.Second, fn)

No event loop, and no coloured functions: any ordinary function becomes concurrent by putting go in front of the call. Channels move values between goroutines, and an unbuffered send blocks until someone receives — that blocking is the synchronisation. Nothing waits for your goroutines when main returns.

Racing and cancelling

select, context
JavaScript
await Promise.race([work(), timeout(1000)]);

const c = new AbortController();
fetch(url, { signal: c.signal });
c.abort();
→
Go
select {
case v := <-work:
	use(v)
case <-time.After(time.Second):
	return errTimeout
}

ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
req = req.WithContext(ctx)

select waits on several channel operations and proceeds with whichever is ready first, so it covers both Promise.race and a non-blocking poll (with a default case). context carries deadlines and cancellation down a call chain and is, by convention, the first parameter of anything that blocks or touches the network.

Sharing state safely

sync, atomic
JavaScript
// one thread: this is always safe
let count = 0;
async function hit() { count++; }

// only workers need real machinery:
// SharedArrayBuffer + Atomics
Atomics.add(shared, 0, 1);
→
Go
// goroutines share memory: this is a race
var mu sync.Mutex
mu.Lock()
count++
mu.Unlock()

var n atomic.Int64
n.Add(1)

var once sync.Once
once.Do(connect)   // lazy init, exactly once

The single-threaded safety you are used to is gone: two goroutines touching the same variable without coordination is a data race and genuinely undefined behaviour. go test -race catches these and belongs in CI from day one. The proverb: share memory by communicating (channels) rather than communicating by sharing memory (mutexes) — but a plain sync.Mutex guarding a struct is honest, common Go.

Everyday standard library

Dates, times and timers

time
JavaScript
Date.now();
new Date().toISOString();

const d = new Date("2026-07-31");
d.getFullYear();

// formatting: Intl or a library
new Intl.DateTimeFormat("en").format(d);

setTimeout(fn, 1000);
const id = setInterval(fn, 1000);
clearInterval(id);
→
Go
time.Now()
time.Now().Format(time.RFC3339)

t, err := time.Parse("2006-01-02", "2026-07-31")
t.Year()

t.Format("Jan 2, 2006 at 3:04pm")
time.Since(start)          // a Duration
t.Add(24 * time.Hour)

time.AfterFunc(time.Second, fn)
tick := time.NewTicker(time.Second)
for range tick.C { fn() }  // tick.Stop() to end

Go's format strings are not YYYY-MM-DD. You write the reference moment — Mon Jan 2 15:04:05 MST 2006 — laid out however you want the output to look, and the same layout string drives both Format and Parse. Durations are a real type (2 * time.Second), so there is no guessing whether an API wants seconds or milliseconds.

Regular expressions

regexp, RE2
JavaScript
const re = /a(b+)c/;

re.test("abbc");           // true
"abbc".match(re)[1];       // "bb"
"abc abc".replace(/b/g, "x");

[..."a1b2".matchAll(/\d/g)];
→
Go
re := regexp.MustCompile(`a(b+)c`)

re.MatchString("abbc")             // true
re.FindStringSubmatch("abbc")[1]   // "bb"
re.ReplaceAllString("abc abc", "x")
// replace is global by default

re.FindAllString("a1b2", -1)

Compile once — usually at package level with MustCompile — and use the raw-string backticks so \d stays \d. The engine is RE2: guaranteed linear time, immune to catastrophic backtracking, but in exchange there is no lookahead, no lookbehind, and no backreferences. When you miss those, restructure the pattern or do a second pass in code.

Bytes and buffers

[]byte, io
JavaScript
const buf = Buffer.from("hi");
buf.toString();
buf.toString("hex");
buf.toString("base64");

new TextEncoder().encode("hi");
new Uint8Array(4);

// streams: ReadableStream, pipe()
→
Go
b := []byte("hi")
string(b)
hex.EncodeToString(b)
base64.StdEncoding.EncodeToString(b)

var buf bytes.Buffer
buf.WriteString("hi")
buf.Bytes()

// streams: io.Reader, io.Writer, io.Copy
io.Copy(dst, src)

[]byte is the Buffer, and converting to or from string copies (strings are immutable). The two-method universe of io.Reader and io.Writer is Go's stream story: files, sockets, HTTP bodies, gzip, hashes all speak it, and io.Copy is pipe(). The bytes package mirrors strings function for function.

Reading and writing files

os, bufio
JavaScript
import { readFile, writeFile }
  from "node:fs/promises";

const text = await readFile("a.txt", "utf8");
await writeFile("out.txt", text);

// line by line
for await (const line of rl) {}

path.join(dir, "a.txt");
→
Go
data, err := os.ReadFile("a.txt")
text := string(data)
err = os.WriteFile("out.txt", data, 0o644)

f, err := os.Open("a.txt")
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
	line := sc.Text()
}

filepath.Join(dir, "a.txt")

No async variant exists because none is needed: calls block the goroutine, not the program, so "concurrent file IO" is just a goroutine. os.ReadFile hands you bytes, and 0o644 is the permission bits JS never asked you about. The Open / defer Close / bufio.Scanner trio is the streaming read for files too big to slurp.

Calling an API

net/http client
JavaScript
const res = await fetch(url);
if (!res.ok) throw new Error(res.status);
const data = await res.json();

await fetch(url, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(payload),
});
→
Go
resp, err := http.Get(url)
if err != nil { return err }      // transport only
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { }

var data Result
json.NewDecoder(resp.Body).Decode(&data)

req, _ := http.NewRequestWithContext(ctx,
	"POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err = http.DefaultClient.Do(req)

Same split as fetch: the error covers transport failure only, and a 404 is a perfectly successful response you must check yourself. Two habits matter: always defer resp.Body.Close() (leaked bodies leak connections), and never ship http.DefaultClient untouched for anything serious — it has no timeout, so pass a context or build a Client with one.

Serving HTTP

net/http server
JavaScript
import express from "express";
const app = express();

app.get("/users/:id", (req, res) => {
  res.json(find(req.params.id));
});

app.use(logger);        // middleware

app.listen(3000);
→
Go
mux := http.NewServeMux()

mux.HandleFunc("GET /users/{id}",
	func(w http.ResponseWriter, r *http.Request) {
		id := r.PathValue("id")
		json.NewEncoder(w).Encode(find(id))
	})

handler := logger(mux)  // middleware wraps

http.ListenAndServe(":3000", handler)

The standard library server is what you deploy — no Express equivalent required. Since Go 1.22 the router understands methods and {id} wildcards, read back with r.PathValue. Every request already runs in its own goroutine, so handlers are concurrent for free (and shared state needs a mutex). Middleware is nothing special: a function that takes an http.Handler and returns one.

Environment and flags

os, flag
JavaScript
process.env.PORT ?? "3000";

process.argv.slice(2);

// flags: commander, yargs
program.option("-v, --verbose");
program.parse();
→
Go
port := os.Getenv("PORT")        // "" if unset
port, ok := os.LookupEnv("PORT") // ok says which

os.Args[1:]

verbose := flag.Bool("v", false, "verbose output")
flag.Parse()
if *verbose { }
flag.Args()   // what's left over

LookupEnv is the one to reach for when "unset" and "set to empty" differ. The flag package is a built-in commander: declare flags, call Parse, and note the pointer — the value lives behind *verbose. It only does -flag style; when you outgrow it into subcommands, spf13/cobra is what most CLIs use.

Logging

log/slog
JavaScript
console.log("started");
console.warn("low disk");
console.error("save failed", err);

// structured: pino, winston
log.info({ id: u.id, plan: "pro" },
  "user created");
→
Go
fmt.Println("started")        // scratch output
log.Printf("low disk: %d%%", pct)

slog.Info("user created",
	"id", u.ID, "plan", "pro")
slog.Error("save failed", "err", err)

slog.SetDefault(slog.New(
	slog.NewJSONHandler(os.Stdout, nil)))

Three tiers: fmt.Println while poking around, log for timestamps and Fatal, and slog — in the standard library since 1.21 — when you want what pino gives you: leveled, structured, key-value logging with a JSON handler for production and a text handler for your terminal. Attach request-scoped fields once with logger.With("reqID", id).

Workflow

Modules and visibility

go.mod
JavaScript
// greet.js
export function hello() {}
export default thing;

// app.js
import { hello } from "./greet.js";
import thing from "some-pkg";

// package.json, node_modules/
→
Go
// greet/greet.go
package greet

func Hello() {}   // exported: capital H
func local()  {}  // package-private

// main.go
import (
	"fmt"
	"example.com/app/greet"
)

// go.mod, shared module cache

Visibility is spelled with a capital letter rather than an export keyword, so renaming hello to Hello publishes it. A directory is a package and its import path is the module path plus that directory. Unused imports fail the build, which is why everyone runs goimports on save.

Tests

go test
JavaScript
// add.test.js
import { test, expect } from "vitest";

test("adds", () => {
  expect(add(1, 2)).toBe(3);
});

// npx vitest
→
Go
// add_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
	if got := add(1, 2); got != 3 {
		t.Errorf("add(1, 2) = %d, want 3", got)
	}
}

// go test ./...

The runner ships with the toolchain: no framework, no config, no dependency. Files end in _test.go, functions start with Test and take *testing.T. There are no assertion helpers on purpose — you compare values and call t.Errorf with a message that says what you got and what you wanted. Table-driven tests (a slice of cases, one loop) are the house style.

The rest of the toolbox

JavaScriptGo
npm installgo get · go mod tidy
package.jsongo.mod
package-lock.jsongo.sum
node_modules/a shared module cache
npm run buildgo build ./...
node app.jsgo run .
prettiergofmt — built in, and not configurable
eslintgo vet · staticcheck
jest · vitestgo test ./...
istanbul / c8go test -cover
tscthe compiler — types aren't optional
nodemonair, or just rebuild; it's fast
npxgo run example.com/tool@latest
JSDocgo doc · pkg.go.dev
Chrome DevTools profilerpprof — built into the toolchain
esbuild + a Dockerfilego build — one static binary
cross-compiling: don'tGOOS=linux GOARCH=arm64 go build

Nothing matches . Try append, goroutine or nil.