Tworzymy własny bundler typów
Nieco ponad rok temu opisałem proces tworzenia prymitywnego bundlera. Nie tak dawno zacząłem się zastanawiać, czy dałoby się go w prosty sposób dostosować do bundle’owania plików z definicjami typów TS-a. A że to wciąż jest faktyczny problem, postanowiłem to sprawdzić.
Teoria
W teorii pliki .d.ts wyglądają bardzo podobnie do zwykłych modułów ES. Definicje typów są podzielone na pliki, które odzwierciedlają podział plików źródłowych aplikacji. Dla przykładu, jeśli mamy takie pliki z kodem:
src/
|- index.ts
|- tools.ts
|- SomeClass.ts
to TS wygeneruje nam takie pliki z definicjami typów:
types/
|- index.d.ts
|- tools.d.ts
|- SomeClass.d.ts
Jeśli przyjrzymy się losowemu plikowi .d.ts (ten jest akurat dla lodashowej funkcji assignWith()), zauważymy znajomą składnię importów i eksportów. Więc z perspektywy bundlera zmienia się na dobrą sprawę głównie rozszerzenie pliku i pojawia się nieco inna składnia (TS zamiast czystego JS-a). Niemniej zdecydowana większość logiki bundlera powinna być możliwa do ponownego użycia bez żadnych zmian.
Przykład
Stwórzmy sobie więc przykład, który będziemy chcieli bundle’ować. Składać się on będzie z trzech plików:
index.d.ts,Fixture.d.ts,Test.d.ts.
Główny plik, index.d.ts, będzie eksportował wszystkie pozostałe typy:
import { Test } from   './Test'; // 1
export { Test }; // 2
export { Fixture } from   './Fixture'; // 3
Żeby było ciekawie, zastosowałem tutaj dwie metody eksportu – import (1) a następnie eksport (2) oraz eksport połączony z importem (3).
Plik Fixture.d.ts zawiera interfejs Fixture:
interface Fixture { // 1
	name: string;
	path: string;
}
export { Fixture }; // 2
Najpierw definiuje on interfejs (1) a następnie go eksportuje (2).
Z kolei plik Test.d.ts eksportuje interfejs Test:
import { Fixture } from './Fixture'; // 2
export interface Test { // 3
	readonly name: string;
	createFixture( name: string ): Fixture; // 1
}
Wykorzystuje on interfejs Fixture (1), który importuje z pliku Fixture.d.ts (2). Natomiast eksport jest tutaj bezpośrednio połączony z deklaracją interfejsu (3).
Spróbujmy zatem zbundle’ować te trzy pliki razem!
Obsługa składni TypeScriptu
Babel jest parserem JS-a, więc nie ma wbudowanej obsługi składni TS-a. Niemniej istnieje oficjalny plugin, który taką obsługę dodaje. Wystarczy go zainstalować:
npm i @babel/plugin-transform-typescript
a następnie dołączyć do naszego parsera w bundlerze:
function processModule( path, isMain = false ) {
    […]
	const ast = parse( code, {
		sourceType: 'module',
		plugins: [ // 1
			[
				'typescript', // 2
				{
					dts: true // 3
				}
			]
		]
	} );
    […]
}
Do parsera dodajemy opcję plugins (1), która pobiera tablicę pluginów  wraz z opcjami dla nich. Dodajemy do niej plugin typescript (2) wraz z opcją dts ustawioną na true (3), która pozwala na parsowanie właśnie plików .d.ts.
Yay, nasz bundler JS-a właśnie stał się bundlerem plików .d.ts!
Ścieżki do plików bez rozszerzeń
I choć nasz bundler już w teorii powinien radzić sobie z plikami .d.ts, to TS ma kilka przypadłości składniowych, które uniemożliwiają mu sensowne działanie. Jedną z nich jest omijanie rozszerzeń plików w importach, np:
import { Fixture } from './Fixture';
Tak naprawdę import odbywa się z pliku Fixture.d.ts, nie zaś – Fixture. Musimy wziąć na to poprawkę i przygotować prostą funkcję, która będzie nam zamieniać ścieżki z importów na poprawne ścieżki do plików:
function createFilePath( importSpecifier ) {
	if ( !importSpecifier.endsWith( '.d.ts' ) ) { // 1
		return `${ importSpecifier }.d.ts`; // 2
	}
	return importSpecifier; // 3
}
Sprawdzamy, czy ścieżka kończy się rozszerzeniem .d.ts (1) i jeśli nie, to po prostu je dodajemy i zwracamy tak zmodyfikowaną ścieżką (2). W innym wypadku zwracamy oryginalną ścieżkę (3).
Teraz wypada dodać tę zmianę do kodu bundlera. Należy podmienić dwie linijki odpowiadające za wczytanie importowanego pliku na poniższy kod:
const importRelativePath = createFilePath( node.source.value );
const depPath = resolvePath( dir, importRelativePath );
modules.push( ...processModule( depPath ) );
A że tę logikę będziemy wykorzystywać też w innym miejscu (spoilers…), to wyciągnijmy sobie ją od razu do osobnej funkcji, processImport():
function processImport( node, dir, modules ) {
	const importRelativePath = createFilePath( node.source.value );
	const depPath = resolvePath( dir, importRelativePath );
	modules.push( ...processModule( depPath ) );
}
Przekazywane parametry to:
node– czyli węzeł AST z importem,dir– katalog pliku importującego (wzięty z funkcjiprocessModule()),modules– tablica modułów (wzięta z funkcjiprocessModule()).
Lepsza obsługa eksportów
Pliki z definicjami typów raczej eksportują typy, więc byłoby miło, gdyby nasz bundler nie wycinał eksportów – ale tylko w głównym pliku (czyli tym, od którego zaczynamy bundle’owanie), bo inaczej dostaniemy niepoprawny składniowo plik, np:
export interface Test {
	[…]
}
export { Test };
Potrzebujemy więc sposobu, aby rozpoznawać, czy aktualnie obsługiwany plik jest tym głównym, czy nie. W tym celu wystarczy dodać parametr isMain do processModule():
function processModule( path, isMain = false ) {
	[…]
}
W chwili, gdy będziemy zaczynać całe bundle’owanie, ustawimy go na true, a we wszystkich innych przypadkach – na false (lub całkowicie pominiemy i pozwolimy przyjąć mu domyślną wartość, czyli właśnie false).
Jednak proste wycinanie wszystkich eksportów z importowanych plików nie zadziała, ponieważ wytnie też konstrukcje typu export interface Test {}, w których deklaracja jest bezpośrednio w eksporcie. Z tego też powodu trzeba zastąpić usuwanie eksportu funkcją handleExport():
function handleExport( path, { isMain, dir, modules } ) {
	const node = path.node;
	if ( node.source ) {
		processImport( node, dir, modules );
	}
	if ( isMain && node.source ) {
		path.replaceWith( exportNamedDeclaration( node.declaration, node.specifiers ) );
		return;
	}
	if ( isMain ) {
		return;
	}
	if ( node.declaration ) {
		path.replaceWith( node.declaration );
		return;
	}
	path.remove();
}
Trochę się tu dzieje, więc przyjrzyjmy się po kolei poszczególnym fragmentom. Na sam początek jest obsługa sytuacji, w których eksport jest połączony z importem (export { Something } from './file'):
const node = path.node; // 1
if ( node.source ) { // 2
	processImport( node, dir, modules ); // 3
}
Na początku pobieramy sobie węzeł eksportu do zmiennej (1), następnie sprawdzamy, czy ma ustawioną własność source (2). To właśnie ona wskazuje na plik, z którego eksportujemy. Jeśli tak, odpalamy na tym eksporcie opisaną już wcześniej funkcję processImport() (3). Wszystkie potrzebne parametry dostajemy z zewnątrz, z funkcji processModule().
Następny fragment dodaje dodatkową logikę dla takich eksportów w głównym pliku. Jest to spowodowane tym, że musimy w nich zamienić eksport z zewnętrznego pliku na eksport lokalnego typu:
export { Fixture } from './Fixture';
// trzeba zmienić na:
export { Fixture };
W tym celu używamy path.replaceWith():
if ( isMain && node.source ) {
	path.replaceWith( exportNamedDeclaration( node.declaration, node.specifiers ) ); // 1
	return; // 2
}
Funkcja exportNamedDeclaration() (1) pochodzi z pakietu @babel/types i służy do tworzenia nowych deklaracji nazwanych eksportów. Przekazujemy do niej dane ze starego eksportu, dzięki czemu powstaje taki sam eksport, ale już bez informacji o zewnętrznym pliku. Z kolei return (2) pozwala zakończyć obsługę tego eksportu w tym miejscu, co pozwala zmniejszyć liczbę zagłębień i else-ów w reszcie funkcji handleExport().
To jest też cała logika dla głównego pliku, więc jeśli w nim jesteśmy, teraz jest pora, by wyjść:
if ( isMain ) {
	return;
}
Następny fragment dotyczy obsługi eksportów połączonych z deklaracją (export interface Test {}) w importowanych plikach (w głównym takich eksportów nie ruszamy, bo i nie ma po co – główny plik powinien bez przeszkód eksportować):
if ( node.declaration ) { // 1
	path.replaceWith( node.declaration ); // 2
	return; // 3
}
Żeby wykryć taki eksport, sprawdzamy, czy zawiera deklarację (1). Jeśli tak, podmieniamy eksport na tę deklarację (2) i wychodzimy z funkcji handleExport() (3).
I w końcu, gdy mamy do czynienia z jakimkolwiek innym rodzajem eksportu, najzwyczajniej w świecie go usuwamy:
path.remove();
To pozwoli pozbyć się z importowanych plików konstrukcji typu export { Fixture }.
I to tyle, stworzyliśmy prymitywny bundler typów!
Możliwe ścieżki rozwoju
Podobnie do “normalnego” bundlera, który był dość prymitywny, tak i bundler typów jest mocno prymitywny i radzi sobie tylko z najprostszymi konstrukcjami. Istnieje zatem szereg możliwych usprawnień, np:
- obsługa aliasów w eksportach – często eksporty zawierają aliasy typu 
export { default as someName } from './File';i obecnie bundler całkowicie sobie nie radzi z takimi aliasami (zostawia je bez zmian), - obsługa składni 
export = SomeThing;– obecnie ta składnia działa częściowo; po prostu nie jest traktowana jako eksport, więc jest zostawiana bez zmian, co powinno działać w głównym pliku, ale już nie w importowanych, - dodanie tree shakingu – wiedząc, jakich typów potrzebujemy, możemy importować tylko te potrzebne z poszczególnych plików 
.d.ts, - dodanie zabezpieczenia przed konfliktami w importach – niektóre importowane typy mogą mieć takie same nazwy (np. 
Nodez modułu obsługującego HTML iNodez modułu obsługującego SVG), więc warto byłoby się przed tym zabezpieczyć, choćby generując unikatowe nazwy dla wszystkich niepublicznych typów. 
To oczywiście nie wszystkie możliwości, jedynie kilka luźnych propozycji. W żadnym razie bundler, jaki stworzyłem, nie jest produkcyjny i przy każdym bardziej zaawansowanym pliku .d.ts wyglebi się na pierwszej nierówności. Do poważnych zastosowań wypadałoby wybrać coś bardziej sprawdzonego, np. rollup-plugin-dts.
Komentarze
Przejdź do komentarzy bezpośrednio na Githubie.