Zero Values и никакого undefined: Чему Go научит JS-разработчика (Часть 2)
- вторник, 16 декабря 2025 г. в 00:00:04
Синтаксис Go глазами того, кто последние пять лет писал на TypeScript.
В первой части мы разобрались с философией Go и настройкой рабочего окружения. Теперь к коду. Эта статья про синтаксис и ключевые концепции Go. Не ждите пересказа документации. Будут сравнения, будут подводные камни, будет код.
Go предлагает несколько вариантов объявления переменных. Звучит как свобода выбора. На практике один способ работает в 90% случаев.
// Явно, со всей бюрократией
var name string = "Alice"
// Go сам выведет тип
var name = "Alice"
// Моржовый оператор это ваш выбор
name := "Alice":= моржовый оператор. Объявляет и инициализирует переменную одновременно. Работает только внутри функций. За пределами функций только var.
В JS const означает "переменную нельзя переназначить". Массив при этом можно мутировать сколько угодно:
const arr = [1, 2, 3];
arr.push(4); // Работает
arr = [5]; // TypeErrorВ Go const это константа времени компиляции. Никаких массивов. Никаких объектов. Только примитивы:
const Pi = 3.14159
const AppName = "myservice"
// Это не скомпилируется:
// const Users = []string{"alice", "bob"}Хотите неизменяемую коллекцию? Не в этой жизни. Используйте дисциплину или создайте функцию, возвращающую новый slice при каждом вызове.
Это то, за что я полюбил Go после TypeScript.
В JavaScript два пустых значения: undefined и null. Тони Хоар назвал null "ошибкой на миллиард долларов". JS решил эту проблему... удвоив её.
let user;
console.log(user); // undefined
console.log(user.name); // TypeError: Cannot read property 'name' of undefinedСколько раз вы видели эту ошибку в Sentry? У меня примерно каждый день в течение трёх лет работы с большим React-проектом.
Go решает иначе. Переменные никогда не бывают undefined. Каждый тип имеет нулевое значение (zero value):
var i int // 0
var s string // "" (пустая строка)
var b bool // false
type User struct {
Name string
Age int
}
var u User // User{Name: "", Age: 0}Объявили переменную - она инициализирована. Всегда.
nil это нулевое значение только для ссылочных типов: указатели, slices, maps, каналы, интерфейсы, функции.
Практический эффект: вместо защитного программирования в стиле if (!user || !user.name) вы пишете:
if user.Name == "" {
// пустое имя
}Целый класс ошибок "Cannot read property 'x' of undefined" просто исчезает.
В TypeScript вы привыкли к классам:
class User {
constructor(public name: string, public age: number) {}
greet(): string {
return `Hi, I'm ${this.name}`;
}
}В Go классов нет. Есть struct - контейнер для данных:
type User struct {
Name string
Age int
}Методы добавляются отдельно:
func (u User) Greet() string {
return fmt.Sprintf("Hi, I'm %s", u.Name)
}В Go нет extends. Хотите переиспользовать код? Используйте встраивание:
type Person struct {
Name string
}
type Employee struct {
Company string
Person // <-- Встраивание, не наследование
}Теперь Employee имеет доступ к полям Person напрямую:
e := Employee{
Company: "Google",
Person: Person{Name: "Alice"},
}
fmt.Println(e.Name) // "Alice" — поле "продвигается" вверхВместо наследования композиция с синтаксическим сахаром. Если Person и Employee имеют метод с одинаковым именем, побеждает Employee.
В TypeScript:
// user.ts
export function createUser() {} // публичная
function validateAge() {} // приватная (внутри модуля)В Go ключевых слов export, public, private нет. Видимость определяется регистром первой буквы:
func CreateUser() {} // Экспортируется (заглавная C)
func validateAge() {} // Не экспортируется (строчная v)То же самое для структур и их полей:
type User struct {
Name string // экспортируется
age int // не экспортируется
}Ещё одно отличие: в Go область видимости это пакет, а не файл.
Все .go файлы в одной директории должны объявлять один и тот же package. Они видят друг друга полностью, как если бы были одним файлом.
/myproject
/handlers
user.go // package handlers
order.go // package handlers — видит всё из user.go
/domain
models.go // package domain — НЕ видит handlersВ JS вы думаете: "Что мне импортировать из файла?". В Go: "Что мне экспортировать из пакета?".
Если вы писали на JS до ES6, вы помните эту классику:
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 3, 3, 3
}, 10);
}Переменная i одна на весь цикл. Все замыкания ссылаются на неё. Решение: let, который создаёт новую переменную на каждой итерации.
Go до версии 1.22 имел ту же проблему:
// До Go 1.22 — баг!
for _, v := range []string{"a", "b", "c"} {
go func() {
fmt.Println(v) // "c", "c", "c"
}()
}Это приводило к реальным production-инцидентам. Я сам однажды потратил два дня на отладку race condition, которая оказалась именно этой ошибкой.
Go 1.22 исправил это. Теперь переменная цикла создаётся заново на каждой итерации — как let в JS. Если вы работаете с Go 1.22+, эта ловушка вас не укусит.
Вот оно. То самое, что бесит всех новичков в Go.
В TypeScript:
try {
const file = await readFile("config.json");
const config = JSON.parse(file);
await saveToDb(config);
} catch (e) {
console.error("Something went wrong:", e);
}Ошибки летят по стеку, пока их кто-то не поймает. Удобно? Да. Предсказуемо? Не очень. Вы когда-нибудь пытались понять, какой именно из трёх вызовов бросил исключение?
В Go ошибки это значения. Функции возвращают их явно:
f, err := os.Open("config.json")
if err != nil {
return nil, fmt.Errorf("failed to open config: %w", err)
}
data, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("failed to read config: %w", err)
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}Да, это три if err != nil подряд. Да, это многословно. Но зато:
Никаких скрытых путей выполнения. Читаете код сверху вниз, понимаете все возможные сценарии.
Каждая ошибка документирована. Сигнатура функции говорит, может ли она упасть.
Контекст добавляется на каждом уровне. fmt.Errorf("failed to open config: %w", err) оборачивает ошибку, сохраняя цепочку.
panic существует, но это не замена исключениям. Используется для багов в коде: индекс за пределами массива, деление на ноль, nil pointer dereference.
Для "файл не найден" или "пользователь ввёл неверный email" только error. Если кто-то говорит использовать panic для бизнес-логики, он не настоящий суслик.
Указатели - главный страх JS-разработчиков в Go. Но если разобраться, они просто делают явным то, что JS скрывает.
Реальность: в JS всё передаётся по значению. Но для объектов это значение это адрес в памяти.
function updateName(user) {
user.name = "Bob"; // мутирует оригинал
user = { name: "Charlie" }; // НЕ меняет оригинал
}
let u = { name: "Alice" };
updateName(u);
console.log(u.name); // "Bob"Вы передаёте копию ссылки. Можете мутировать объект по этой ссылке. Не можете переназначить саму переменную.
В Go вы выбираете, что передать:
Передача по значению это копирование:
func updateName(u User, newName string) {
u.Name = newName // u — это КОПИЯ
}
user := User{Name: "Alice"}
updateName(user, "Bob")
// user.Name всё ещё "Alice"Передача по указателю как в JS:
func updateName(u *User, newName string) {
u.Name = newName // u указывает на ОРИГИНАЛ
}
user := User{Name: "Alice"}
updateName(&user, "Bob") // & берёт адрес
// user.Name теперь "Bob"& = "взять адрес". * = "значение по адресу".
В JS this всегда ссылка. В Go вы выбираете:
// Value receiver — метод работает с копией
func (u User) GetName() string {
return u.Name
}
// Pointer receiver — метод работает с оригиналом
func (u *User) SetName(name string) {
u.Name = name
}Правило: если метод мутирует состояние это pointer receiver. Если нет - value receiver. Для больших структур почти всегда pointer, иначе копирование съест производительность.
В JS для гарантированной очистки используют try...finally:
let resource;
try {
resource = open();
// ... работа с ресурсом
} finally {
if (resource) resource.close();
}open() вверху, close() внизу. Между ними 50 строк кода. Забыть добавить close() дело пяти минут.
Go предлагает defer:
resource, err := open()
if err != nil {
return err
}
defer resource.Close() // <-- Сразу после открытия!
// ... 50 строк работы с ресурсом
// resource.Close() вызовется автоматически при выходе из функцииdefer откладывает выполнение до выхода из функции. Работает даже если случился panic. Главное, что открытие и закрытие рядом. Невозможно забыть.
LIFO-порядок. Несколько defer выполняются в обратном порядке:
defer fmt.Println("first")
defer fmt.Println("second")
defer fmt.Println("third")
// Вывод: third, second, firstАргументы вычисляются сразу:
for i := 0; i < 3; i++ {
defer fmt.Println(i) // Вывод: 2, 1, 0
}i захватывается в момент создания defer, не в момент выполнения.
В TypeScript вы явно указываете, что класс реализует интерфейс:
interface Reader {
read(p: Uint8Array): number;
}
class MyFile implements Reader { // <-- явное объявление
read(p: Uint8Array): number { /* ... */ }
}В Go неявная реализация:
type Reader interface {
Read(p []byte) (n int, err error)
}
type MyFile struct { /* ... */ }
func (f MyFile) Read(p []byte) (n int, err error) {
// ...
}MyFile реализует Reader автоматически, потому что имеет метод Read с правильной сигнатурой. Никаких implements. Если утка крякает как утка это суслик утка.
Это делает Go невероятно гибким для тестирования. Хотите замокать HTTP-клиент? Создайте интерфейс с нужными методами. Передайте мок. Оригинальный код ничего не знает о вашем интерфейсе и не должен знать.
В TypeScript any отключает проверку типов. Пишите что хотите, компилятор закроет глаза.
В Go any (alias для interface{}) это безопасный unknown:
func print(value any) {
// value.ToLower() // Ошибка компиляции!
if str, ok := value.(string); ok {
fmt.Println(str) // Теперь ok
}
}Пока не проверите тип ничего с any сделать нельзя. Type assertion обязателен.
Мы разобрали синтаксис и базовые парадигмы. В следующей части конкурентность: горутины, каналы, context. То, ради чего многие переходят на Go.
Предыдущая часть: Бросаем Event Loop, переходим на Горутины: Go для JS-девелоперов (Часть 1)