
Синтаксис 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 ключевых слов 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: "Что мне экспортировать из пакета?".
Цикл 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 подряд. Да, это многословно. Но зато:
Никаких скрытых путей выполнения. Читаете код сверху вниз, понимаете все возможные сценарии.
Каждая ошибка документирована. Сигнатура функции говорит, может ли она упасть.
Контекст добавляется на каждом уровне.
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)

NN1
15.12.2025 18:30Справедливости ради в TS/JS наследование это всего лишь удобство синтаксиса.
На деле мы можем как и раньше менять прототипы и использовать defineObject.
Также и с методами их можно отдельно писать как и делали раньше.
Просто это достало ;)

Zukomux
15.12.2025 18:30Отказ от undefined не спасет от криворуких кодеров. Ну не тут, так в другом месте напишут такое, что будет падать. И сделают они это со свойственной им тупизной. ТС, конечно, накидывает на вентилятор - кто сейчас в js проверяет опциональные свойства когда для этого есть специальный оператор?
vanxant
За статью плюс, но про null и undefined супротив zero value принципиально не согласен. Это очень грамотная фишка js
Zenitchik
Это просто следствие использования вариантного типа. Иначе бы не вышло.
null - это zero value для object, а undefined - zero value для variant.
winkyBrain
Поддержу. Go не знаю, и мне вот вообще не понятно, как объявить к примеру юзера, возраст которого опционален - то есть, по заветам JS, number или undefined. Go в данном случае всё равно установит возраст 0 по умолчанию? Как тогда отличить пользователя, который возраст никогда не указывал, от того, возраст которого действительно(и внезапно) 0?
allishappy
cmyser
Плохая идея
Это заставляет писать проверку if =!nil каждый раз при работе с объемом
theonevolodya
Но это то, что нужно. Если пользователь не указал возраст, то его возраст неизвестен. И нужно всегда проверять, известен ли нам возраст пользователя
cmyser
Тишка ниже написал более серьезную проблему
Tishka17
Проверка на nil ещё ладно. Тут проблема, в другом - теперь мы не отличаем указать на возраст и опциональный возраст. И как следствие - не можем сделать not nil указатель.
cmyser
Да, сам с этим сталкивался, достаточно больно
alexs963
Всегда инициализировать это поле со значением -1?
evgeniy_kudinov
Предложу как один из вариантов создать свой тип Age с методом Value() (uint, bool). Если Value вернет true, значит, значение в типе верное (инициализированное). При возврате false будет означать undefined.
NeXackerr
Просто в struct указать что поле age будет *int (ссылкой на место в памяти где может будет int), а такой тип может быть nil