Wieki temu Isaac Z. Schlueter napisał artykuł na temat projektowania asynchronicznyczh API. Przestrzegł w nim przed wypuszczaniem na świat Zalgo.

Zalgo?

Zalgo czyha poza kurtyną poznania, by swymi długimi mackami siać zamęt i terror w umysłach ludzi niezdolnych do pojęcia jego wszechpotężnej grozy. I nadchodzi, jest coraz bliżej…

A w kontekście artykułu chodzi po prostu o API, które czasami zachowuje się asynchronicznie, a czasami – synchronicznie. Przez to trudno jest w pełni przewidzieć jego zachowanie, a tym samym – zarządzać przepływem w aplikacji. Żeby zatem nie wypuszczać Zalgo w swoim kodzie i nie powodować szaleństwa wśród innych programistów, wypada tworzyć API, które zawsze są synchroniczne albo zawsze asynchroniczne.

Zalgo!

Przypomniałem sobie o asynchronicznym Zalgo przy pisaniu nowego projektu. Moje publiczne API zwykle są asynchroniczne, więc, niewiele myśląc, naskrobałem coś takiego:

async function publicAPI( data ) {
	return data.repeat( 2 );
}

Teraz tylko jeszcze dodać jeszcze walidację przekazanych danych:

async function publicAPI( data ) {
	if ( typeof data !== 'string' ) {
		throw new TypeError( 'I need a string!!!' );
	}

	return data.repeat( 2 );
}

Voilà! Tylko że nie zachowywało się to tak, jak publiczne API w moich innych bibliotekach… Porównałem zatem z jakimś innym moim kodem:

function publicAPI( data ) {
	if ( typeof data !== 'string' ) {
		throw new TypeError( 'I need a string!!!' );
	}

	return new Promise( ( resolve ) => {
		resolve( data.repeat( 2 ) );
	} );
}

Co Bardziej Rozgarnięty Czytelnik w tym momencie pewnie już wie, gdzie czai się mroczn leży problem. Większość moich API wypuszczała Zalgo. W chwili, gdy występował w nich błąd, błędy były rzucane synchronicznie, podczas gdy wynik był zwracany asynchronicznie. Funkcja asynchroniczna z kolei zawsze rzuca błędy i zwraca wynik asynchronicznie:

function zalgoAPI( data ) {
	if ( typeof data !== 'string' ) {
		throw new TypeError( 'I need a string!!!' );
	}

	return new Promise( ( resolve ) => {
		resolve( data.repeat( 2 ) );
	} );
}

async function safeAPI( data ) {
	if ( typeof data !== 'string' ) {
		throw new TypeError( 'I need a string!!!' );
	}

	return data.repeat( 2 );
}

try {
	zalgoAPI( 1 );
} catch( error ) {
	console.error( error );
}

safeAPI( 1 ).catch( console.error );

Problem robi się dość oczywisty, gdy chcemy dorobić obsługę błędów dla takiego zalgoconego API. Bo musimy dorobić ją podwójnie: jedną, synchroniczną, dla walidacji danych i drugą, asynchroniczną, dla błędów, jakie mogą pojawić się w czasie rozwiązywania wynikowej obiecanki:

try {
	zalgoAPI( someValue ).catch( console.error );
} catch( error ) {
	console.error( error );
}

W moim kodzie najczęściej problem omijałem, stosując składnię async/await, która wszystkie błędy pozwala łapać przy pomocy try/catch:

function zalgoAPI( data ) {
	if ( typeof data !== 'string' ) {
		throw new TypeError( 'I need a string!!!' );
	}

	return new Promise( ( resolve, reject ) => {
		reject( new Error( 'A kuku!' ) );
	} );
}

( async function() {
	try {
		await zalgoAPI( 1 );
	} catch( error ) {
		console.error( error ); // Błąd walidacji.
	}

	try {
		await zalgoAPI( 'string' );
	} catch( error ) {
		console.error( error ); // Błąd z obiecanki.
	}
}() );

Nie zmienia to faktu, że powinno to działać także poza składnią async/await, przy wykorzystaniu samych Promise. Czeka mnie zatem nieco refaktoryzacji…

Chociaż dziwne, że tak długo wydawało mi się wręcz, że mój sposób jest bardziej intuicyjny. Czyżbym zaraził się szaleństwem Zalgo?