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. Компилятор Go собирает их вместе, для кода внутри пакета они как один файл.

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

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


  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. m0tral
        15.12.2025 18:30

        У вас сломанный пример, возраст не может быть 0, setter не должен позволять делать такого, и как указали ниже можно делать как в Сях -1, во взрослых языках value типы могут иметь nullable значения, если уж сильно хочется.


        1. Metotron0
          15.12.2025 18:30

          Чему равен возраст месячного ребёнка, если считать в целых числах?


      1. F0rzend
        15.12.2025 18:30

        Это актуальная проблема. Как вы смотрите на создание ValueObject?

        type UserAge struct {
          age int
          filled bool
        }

        Не удобно писать код с телефона, но, надеюсь, мысль передал. Поля приватные, экземпляр создается через конструктор. Там же может быть валидация. Публичный геттер может возвращать два значения для проверки и обработки. Zerovalue структуры будет иметь filled false, что так же корректно обрабатывается


  1. NN1
    15.12.2025 18:30

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

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

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

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


    1. Arty7 Автор
      15.12.2025 18:30

      Именно так. class в JS сахар над прототипами, появившийся в ES6 чтобы не писать Object.create() и Object.defineProperty() руками.

      Go в этом смысле честнее: нет притворяющегося классом синтаксиса. Структура это данные. Методы отдельно. Композиция через встраивание явная. Никто не ожидает поведения "настоящего" ООП и не удивляется, когда его нет.

      В JS классы создали иллюзию привычного ООП для людей из Java/C#. Иллюзия работает, пока не полезешь в _proto_ или не попробуешь сделать множественное наследование. Тогда вспоминаешь, что под капотом всё те же прототипы.


  1. Zukomux
    15.12.2025 18:30

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


    1. MyraJKee
      15.12.2025 18:30

      100%... Падает со страшным грохотом так же легко как в js


    1. Arty7 Автор
      15.12.2025 18:30

      Про криворуких кодеров согласен, от них не спасёт никакой язык. Можно и на Rust написать что-то, что будет падать, если очень постараться. Но речь не о защите от злого умысла, речь о том, какой код получается по умолчанию.

      Про ?. (optional chaining) справедливое замечание. Действительно, с ES2020 ситуация стала лучше:

      const name = user?.profile?.name ?? "Anonymous";

      Но тут два нюанса:

      ?. это костыль поверх проблемы, а не её решение. Вы всё равно должны помнить, где нужен ?., а где нет. В Go нулевое значение это дефолт, а не исключение. Разница в когнитивной нагрузке.
      ?. появился в 2020. Sentry-ошибки из статьи это реальный опыт 2017-2019 годов. Легаси-код без optional chaining живёт и здравствует.

      Но вы правы, в современном JS эта проблема смягчена. Можно было упомянуть ?. как контрпример. Статья фокусируется на различиях в философии языков, а не на современных workaround'ах, но замечание учту, спасибо.


  1. Octagon77
    15.12.2025 18:30

    Все .go файлы в одной директории должны объявлять один и тот же package. Они видят друг друга полностью, как если бы были одним файлом.

    В такой форме это путает новичка. Если все файлы должны иметь одну и ту же строку, то зачем она вообще нужна? Ну, может быть если файлы вдруг перескочат из одного каталога в другой...

    Если бы написать чуть полнее, скажем про go.work, go.mod и то, когда имена пакета и директории могут не совпадать - будет понятно, что обязательный package не 100% исторически сложившееся уродство, а в нём есть и некоторая логика и некий потенциал на мало ли что в будущем. Мне кажется, что в статье изложено меньше чем квант.

    Про "видят друг друга полностью" - неверно. Файлы друг друга не видят вообще, их видит команда go. Поэтому в одном каталоге может быть несколько .go файлов в каждом из которых есть и "package main" и "func main" и команды типа "go run m_v3.go p1.go p2.go" и "go run m_v1.go p1.go p2.go" будут работать, а комманда "go run ." - нет.

    Такие мелочи важны как раз для начинающих, у них и main может быть в разных вариантах, и раскладка по модулям плавать.

    Объявляет и инициализирует переменную одновременно

    Тут я бы непременно вставил слово "новую", как минимум. Иначе это тоже запутывает новичка. А про "новую" он, даже читая по верхам, точно вспомнит как только слева от := окажется больше одной переменной.

    В JavaScript два пустых значения: undefined и null. Тони Хоар назвал null "ошибкой на миллиард долларов". JS решил эту проблему... удвоив её.

    Он не удвоил, он именно решил... для случая когда возможны ровно три состояния - известно, неизвестно, не спрашивали. Так нередко и бывает, поэтому в комментариях некоторые и пишут что получилось хорошо. Как только состояний больше - JS требует задуматься, а весь огород вокруг undefined и nil только мешает. В Go такого нет, да.

    То же самое относится и к обработке ошибок. Пока состояний два - получилось и упс, try да ? работают идеально. Как только задача не столь тривиальна - метод Go удобнее чем баян который можно нагородить вокруг типов исключений и отметки мест откуда они.


    1. Arty7 Автор
      15.12.2025 18:30

      Спасибо за развёрнутый разбор.

      Про package и организацию модулей. Да, я сознательно упростил. Статья для JS-разработчиков, которые только смотрят в сторону Go. Полноценное объяснение go.mod, go.work, рабочих пространств и случаев несовпадения имени пакета и директории это отдельная статья. Здесь ментальная модель "пакет = папка" работает в 95% случаев для входа в язык.

      Но согласен, что фраза "видят друг друга полностью" технически неаккуратна. Файлы видит go, а не друг друга. И ваш пример с go run m_v3.go p1.go p2.go vs go run . отличная иллюстрация того, как упрощение может сбить с толку. Исправлю формулировку.

      Про := и "новую" согласен на 100%. Там действительно нужно слово "новую", иначе при x, err := …; y, err := … начинающий запутается. Спасибо, поправлю.

      Про undefined/null и "удвоение проблемы". Тут я остаюсь при своём. Три состояния "известно / неизвестно / не спрашивали" это нюанс, который появляется в конкретных доменных задачах. Для 80% кода это просто два способа получить TypeError. Про "решил" - вопрос вкуса. Моё мнение: если типичный вопрос на собеседовании "в чём разница между null и undefined" это признак неудачного дизайна, а не решения проблемы.

      Про обработку ошибок согласен частично. Для сложных сценариев Go-подход действительно удобнее благодаря явности. Но try/catch тоже не просто так существуют 30+ лет. Оба подхода имеют своё место. Статья намеренно показывает Go в положительном свете. Это материал для тех, кто рассматривает переход.