Strona główna / Artykuły / Wybór języka w zależności od charakteru problemu: lekcje z przeniesienia Go do TypeScript

Wybór języka w zależności od charakteru problemu: lekcje z przeniesienia Go do TypeScript

To, czego uczy wybór Go zamiast domyślnego kompilatora TypeScript na temat dopasowywania narzędzi do rodzaju zadań, przywiązywania wagi do szybkości działania narzędzi oraz stopniowego przenoszenia dużych baz kodu.

1223 słów

Czekanie blisko minuty na funkcję autodopisywania w dużym monorepo z TypeScriptem to dobrze znany problem, który sprawia trudności również programistom mobilnym podczas indeksowania dużego projektu w Xcode. Zespół odpowiedzialny za TypeScript zmagał się właśnie z tym problemem, a sposób, w jaki postanowił go rozwiązać, stanowi przydatny przykład podejmowania decyzji technologicznych na podstawie faktów, a nie modnych trendów. Ten artykuł omija sensacyjne tytuły i przedstawia rozumowanie inżynieryjne, które można zastosować we własnych narzędziach i aplikacjach, niezależnie od tego, czy używasz TypeScriptu, Swifta, czy obu.

Co ogłosiła Microsoft

W marcu 2025 roku Anders Hejlsberg, twórca zarówno TypeScriptu, jak i C#, poinformował, że Microsoft pracuje nad stworzeniem natywnego portu kompilatora TypeScriptu oraz narzędzi językowych do niego. Wielu ludzi przypuszczało, że celem będzie Rust lub być może C++. Okazało się jednak, że chodzi o Go.

Wdrożenie natywne, o kryptonimie Corsa, stanowi podstawę TypeScript 7.0. W chwili pisania tego tekstu przewidywano wydanie w połowie 2026 roku, dlatego przed planowaniem migracji sprawdź oficjalny blog TypeScript w celu poznania aktualnego stanu rzeczy.

Ujawnione wyniki były imponujące. Czas ładowania bazy kodu VS Code w edytorze spadł z około 9,6 sekundy do 1,2 sekundy, zużycie pamięci zmniejszyło się mniej więcej o połowę, a Microsoft opisał ładowanie projektu jako o około 8 razy szybsze ogólnie, przy czym sprawdzanie typów było aż 10 razy szybsze. Jeśli chcesz dowiedzieć się, jak te ulepszenia wpływają na rzeczywiste projekty, omówiliśmy to osobno w artykule co zmieniają przeredagowania w języku Go bez ingerencji w twój kod. Reszta tego tekstu koncentruje się na samej decyzji.

Dlaczego Go zamiast Rust

Zespół szukał języka o najniższym poziomie abstrakcji, który nadal generował kod natywny na wszystkich platformach, które musi obsłużyć TypeScript, oferując jednocześnie dobrą wbudowaną zdolność do równoległego wykonywania zadań. Go odpowiada temu opisowi.

Istotne były jeszcze dwa czynniki. Po pierwsze, obciążenie pracy: sprawdzanie typów w dużym projekcie polega głównie na równoległym sprawdzaniu wielu plików, a następnie łączeniu wyników, a goroutines doskonale odpowiadają temu schematowi. Po drugie, istniejący kod: kompilator to ogromna baza kodu JavaScript, która opiera się na zbieraniu śmieci i stosuje dość funkcjonalny styl programowania. Go również posiada zbieracz śmieci i podobną strukturę, więc kod można było dokładnie przetłumaczyć. Przeniesienie go na Rust wymusiłoby znacznie głębszą przebudowę dotyczącą koncepcji własności i czasów trwania, co stanowiłoby duży koszt dla dużego zespołu pracującego z ogromną bazą kodu do przeniesienia.

Prościej mówiąc, zespół nie wybrał najmodniejszego języka. Wybrał ten, który pasował do jego modelu równoczesności i pozwalał inżynierom być produktywnymi. To jest decyzja architektoniczna, taka sama, jaką podejmujesz przy wyborze między UIKit a SwiftUI albo między Combine a zwykłym async/await dla określonej funkcji.

Ta sama koncepcja w Swift

Rozwijający aplikacje w Swift już wcześniej spotykali się z tym problemem. Strukturyzowana równoczesność została dodana do Swift właśnie po to, aby możliwe było wykonywanie kosztownych operacji równolegle bez rezygnacji z poprawności. Poniższy fragment ilustruje koncepcję „sprawdzenie wielu plików jednocześnie, a następnie połączenie ich” za pomocą grupy zadań: każdy plik ma swoje własne zadanie potomne, a zadanie nadrzędne zbiera informacje diagnostyczne po zakończeniu tych zadań. Należy zauważyć, że fragment ten jest napisany w Swift, mimo iż ilustruje wzorzec używany przez kompilator Go z goroutines.

// Swift concurrency: parallel "type-checking" of files, similar in spirit
// to how Go's goroutines let TypeScript check many files at once
func checkFiles(_ paths: [String]) async -> [Diagnostic] {
    await withTaskGroup(of: [Diagnostic].self) { group in
        for path in paths {
            group.addTask {
                await checkSingleFile(path) // runs concurrently
            }
        }
        var results: [Diagnostic] = []
        for await diagnostics in group {
            results.append(contentsOf: diagnostics)
        }
        return results
    }
}

Warto zwrócić uwagę na kilka kwestii. Funkcja withTaskGroup gwarantuje, że każde zadanie potomne zostanie zakończone przed powrotem funkcji, dzięki czemu żadna praca nie „wycieka” poza zakres. Wyniki przychodzą w kolejności zakończenia, a nie w kolejności wprowadzania danych, więc jeśli ważna jest kolejność diagnostyki, trzeba je później posortować. Ponadto paralelizm jest bezpieczny tylko dlatego, że każda weryfikacja jest niezależna – to właśnie ta cecha sprawia, że weryfikacja typów doskonale pasuje do modelu Go.

Jeśli używasz Swift 6, już dokonałeś takiej transakcji: płacisz z góry za ścisłe kontrole współbieżności i bezpieczeństwa, a w zamian otrzymujesz szybszą kompilację oraz bardziej stabilne zachowanie w czasie wykonywania. Microsoft postawił podobną stawkę ze swoim kompilatorem.

Lekcje, które można zastosować w własnych projektach

  • Szybkość narzędzi jest cechą produktu. Powolne procesy kompilacji oraz opóźnione funkcje autodopasowywania nakładają codzienną, w dużej mierze niewidoczną presję na każdego programistę w zespole. Prace nad wydajnością w pipeline’ach kompilacyjnych, edytorach i systemach indeksowania są równie istotne jak prace nad wydajnością samej aplikacji dostępnej dla użytkowników.
  • Narzędzie powinno być wybrane na podstawie problemu, a nie trendów. Niezależnie od tego, czy wybierasz MVVM, SwiftData czy Combine, właściwa decyzja wynika z tego, jak dane faktycznie przepływają w twoim systemie, a nie z tego, co jest popularne w mediach społecznościowych danego miesiąca.
  • Przepisywanie kodu może odbywać się stopniowo. Zespół odpowiedzialny za TypeScript przeniósł istniejący kompilator tak wiernie, jak to możliwe, zamiast go przeprojektować, dzięki czemu nowa wersja daje te same wyniki co stara. To mocny argument przeciwko budowie całej architektury aplikacji w ciągu jednego sprintu.

Kiedy nie powinieneś naśladować tego przykładu? Jeśli obecny stack nie stanowi wąskiego gardła, przeniesienie go nic ci nie da poza ryzykiem. Najpierw zmierz, upewniając się, że wolna część rzeczywiście znajduje się w warstwie, którą zamierzasz zastąpić.

Rozpoznawanie sygnałów ostrzegawczych

Symptomy, które doprowadziły do powstania tego projektu, często pojawiają się w każdym dużym kodzie: spowalniona inferencja typów, opóźnione informacje od edytora oraz ciągle rosnące diagramy budowania. Rozwiązaniem nie zawsze jest nowa platforma. Czasami oznacza to dokładne przyjrzenie się temu, co twoje narzędzia faktycznie robią w tle, oraz zmianę tej części, która wykonywa zbyt wiele pracy.

Główne wnioski

  • Wybrano Go, ponieważ jego natywna kompilacja, zbieranie śmieci oraz konkurencja oparta na goroutine’ach pasowały zarówno do obciążenia związanego z weryfikacją typów, jak i do struktury istniejącego kodu.
  • Szybsze działanie wynikało z wiernego przeniesienia kodu, które zapewniało spójność zachowania mimo zmian w podstawowym silniku wykonywania.
  • Traktuj opóźnienia w narzędziach deweloperskich jako rzeczywisty koszt, który warto zrekompensować wysiłkiem inżynierskim.
  • Następnym razem, gdy proces budowania jest powolny, zadaj to samo pytanie, które zadał zespół TypeScript: jakie narzędzie faktycznie pasuje do charakteru tego problemu?
  • Literatura pokrewna