Zero value for string in Go
Zero value for string in Go

Синтаксис Go глазами того, кто последние пять лет писал на TypeScript.

В первой части мы разобрались с философией Go и настройкой рабочего окружения. Теперь к коду. Эта статья про синтаксис и ключевые концепции Go. Не ждите пересказа документации. Будут сравнения, будут подводные камни, будет код.

Переменные: три способа сделать одно и то же

Go предлагает несколько вариантов объявления переменных. Звучит как свобода выбора. На практике один способ работает в 90% случаев.

// Явно, со всей бюрократией
var name string = "Alice"

// Go сам выведет тип
var name = "Alice"

// Моржовый оператор это ваш выбор
name := "Alice"

:= моржовый оператор. Объявляет и инициализирует переменную одновременно. Работает только внутри функций. За пределами функций только var.

А что с const?

В 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 при каждом вызове.

Нулевые значения. Прощай, undefined

Это то, за что я полюбил 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?

nil это нулевое значение только для ссылочных типов: указатели, slices, maps, каналы, интерфейсы, функции.

Практический эффект: вместо защитного программирования в стиле if (!user || !user.name) вы пишете:

if user.Name == "" {
    // пустое имя
}

Целый класс ошибок "Cannot read property 'x' of undefined" просто исчезает.

struct вместо class. Данные без багажа

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

Видимость: заглавная буква вместо export

В TypeScript:

// user.ts
export function createUser() {}  // публичная
function validateAge() {}        // приватная (внутри модуля)

В Go ключевых слов exportpublicprivate нет. Видимость определяется регистром первой буквы:

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: "Что мне экспортировать из пакета?".

Цикл for: Go исправил ту же ошибку, что и let

Если вы писали на 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+, эта ловушка вас не укусит.

Обработка ошибок: if err != nil это не шутка

Вот оно. То самое, что бесит всех новичков в 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 подряд. Да, это многословно. Но зато:

  1. Никаких скрытых путей выполнения. Читаете код сверху вниз, понимаете все возможные сценарии.

  2. Каждая ошибка документирована. Сигнатура функции говорит, может ли она упасть.

  3. Контекст добавляется на каждом уровнеfmt.Errorf("failed to open config: %w", err) оборачивает ошибку, сохраняя цепочку.

А что с panic?

panic существует, но это не замена исключениям. Используется для багов в коде: индекс за пределами массива, деление на ноль, nil pointer dereference.

Для "файл не найден" или "пользователь ввёл неверный email" только error. Если кто-то говорит использовать panic для бизнес-логики, он не настоящий суслик.

Указатели: то, чего нет в JS

Указатели - главный страх JS-разработчиков в Go. Но если разобраться, они просто делают явным то, что JS скрывает.

Миф: "в JS объекты передаются по ссылке"

Реальность: в JS всё передаётся по значению. Но для объектов это значение это адрес в памяти.

function updateName(user) {
    user.name = "Bob"; // мутирует оригинал
    user = { name: "Charlie" }; // НЕ меняет оригинал
}

let u = { name: "Alice" };
updateName(u);
console.log(u.name); // "Bob"

Вы передаёте копию ссылки. Можете мутировать объект по этой ссылке. Не можете переназначить саму переменную.

Go делает это явным

В 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"

& = "взять адрес". * = "значение по адресу".

Ресивер методов: *T vs T

В 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, иначе копирование съест производительность.

defer: finally, который не бесит

В 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-клиент? Создайте интерфейс с нужными методами. Передайте мок. Оригинальный код ничего не знает о вашем интерфейсе и не должен знать.

any в Go это не any из TypeScript

В 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)

Комментарии (14)


  1. vanxant
    15.12.2025 18:30

    За статью плюс, но про null и undefined супротив zero value принципиально не согласен. Это очень грамотная фишка js


    1. Zenitchik
      15.12.2025 18:30

      Это просто следствие использования вариантного типа. Иначе бы не вышло.
      null - это zero value для object, а undefined - zero value для variant.


    1. winkyBrain
      15.12.2025 18:30

      Поддержу. Go не знаю, и мне вот вообще не понятно, как объявить к примеру юзера, возраст которого опционален - то есть, по заветам JS, number или undefined. Go в данном случае всё равно установит возраст 0 по умолчанию? Как тогда отличить пользователя, который возраст никогда не указывал, от того, возраст которого действительно(и внезапно) 0?


      1. allishappy
        15.12.2025 18:30

        type User struct {
          Age *int
        }


        1. cmyser
          15.12.2025 18:30

          Плохая идея

          Это заставляет писать проверку if =!nil каждый раз при работе с объемом


          1. theonevolodya
            15.12.2025 18:30

            Но это то, что нужно. Если пользователь не указал возраст, то его возраст неизвестен. И нужно всегда проверять, известен ли нам возраст пользователя


            1. cmyser
              15.12.2025 18:30

              Тишка ниже написал более серьезную проблему


          1. Tishka17
            15.12.2025 18:30

            Проверка на nil ещё ладно. Тут проблема, в другом - теперь мы не отличаем указать на возраст и опциональный возраст. И как следствие - не можем сделать not nil указатель.


            1. cmyser
              15.12.2025 18:30

              Да, сам с этим сталкивался, достаточно больно


      1. alexs963
        15.12.2025 18:30

        Всегда инициализировать это поле со значением -1?


      1. evgeniy_kudinov
        15.12.2025 18:30

        Предложу как один из вариантов создать свой тип Age с методом Value() (uint, bool). Если Value вернет true, значит, значение в типе верное (инициализированное). При возврате false будет означать undefined.


      1. NeXackerr
        15.12.2025 18:30

        Просто в struct указать что поле age будет *int (ссылкой на место в памяти где может будет int), а такой тип может быть nil


  1. NN1
    15.12.2025 18:30

    Справедливости ради в TS/JS наследование это всего лишь удобство синтаксиса.

    На деле мы можем как и раньше менять прототипы и использовать defineObject.

    Также и с методами их можно отдельно писать как и делали раньше.

    Просто это достало ;)


  1. Zukomux
    15.12.2025 18:30

    Отказ от undefined не спасет от криворуких кодеров. Ну не тут, так в другом месте напишут такое, что будет падать. И сделают они это со свойственной им тупизной. ТС, конечно, накидывает на вентилятор - кто сейчас в js проверяет опциональные свойства когда для этого есть специальный оператор?