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

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

Arty7 Автор
15.12.2025 18:30Именно так.
classв JS сахар над прототипами, появившийся в ES6 чтобы не писатьObject.create()иObject.defineProperty()руками.Go в этом смысле честнее: нет притворяющегося классом синтаксиса. Структура это данные. Методы отдельно. Композиция через встраивание явная. Никто не ожидает поведения "настоящего" ООП и не удивляется, когда его нет.
В JS классы создали иллюзию привычного ООП для людей из Java/C#. Иллюзия работает, пока не полезешь в
_proto_или не попробуешь сделать множественное наследование. Тогда вспоминаешь, что под капотом всё те же прототипы.

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

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'ах, но замечание учту, спасибо.

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 удобнее чем баян который можно нагородить вокруг типов исключений и отметки мест откуда они.

Arty7 Автор
15.12.2025 18:30Спасибо за развёрнутый разбор.
Про
packageи организацию модулей. Да, я сознательно упростил. Статья для JS-разработчиков, которые только смотрят в сторону Go. Полноценное объяснениеgo.mod,go.work, рабочих пространств и случаев несовпадения имени пакета и директории это отдельная статья. Здесь ментальная модель "пакет = папка" работает в 95% случаев для входа в язык.Но согласен, что фраза "видят друг друга полностью" технически неаккуратна. Файлы видит go, а не друг друга. И ваш пример с
go run m_v3.go p1.go p2.govsgo run .отличная иллюстрация того, как упрощение может сбить с толку. Исправлю формулировку.Про
:=и "новую" согласен на 100%. Там действительно нужно слово "новую", иначе приx, err := …; y, err := …начинающий запутается. Спасибо, поправлю.Про
undefined/nullи "удвоение проблемы". Тут я остаюсь при своём. Три состояния "известно / неизвестно / не спрашивали" это нюанс, который появляется в конкретных доменных задачах. Для 80% кода это просто два способа получитьTypeError. Про "решил" - вопрос вкуса. Моё мнение: если типичный вопрос на собеседовании "в чём разница между null и undefined" это признак неудачного дизайна, а не решения проблемы.Про обработку ошибок согласен частично. Для сложных сценариев Go-подход действительно удобнее благодаря явности. Но
try/catchтоже не просто так существуют 30+ лет. Оба подхода имеют своё место. Статья намеренно показывает Go в положительном свете. Это материал для тех, кто рассматривает переход.
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
m0tral
У вас сломанный пример, возраст не может быть 0, setter не должен позволять делать такого, и как указали ниже можно делать как в Сях -1, во взрослых языках value типы могут иметь nullable значения, если уж сильно хочется.
Metotron0
Чему равен возраст месячного ребёнка, если считать в целых числах?
F0rzend
Это актуальная проблема. Как вы смотрите на создание ValueObject?
Не удобно писать код с телефона, но, надеюсь, мысль передал. Поля приватные, экземпляр создается через конструктор. Там же может быть валидация. Публичный геттер может возвращать два значения для проверки и обработки. Zerovalue структуры будет иметь filled false, что так же корректно обрабатывается