Siedem nawyków, które ułatwiają następnemu programiście modyfikację kodu
Klauzule ochronne, nazwy ujawniające intencję, granice API, precyzyjne typy, czyste funkcje decyzyjne, ustrukturyzowane błędy oraz skoncentrowane prośby o aktualizację, wraz z przykładami w JavaScript.
Źródła: 7 Coding Habits That Separate Senior Developers From the Rest
Uczenie się kolejnego frameworka ostatecznie przestaje sprawiać, że stajesz się wyraźnie lepszym inżynierem. To, co naprawdę przynosi korzyści, to sposób, w jaki piszesz i modyfikujesz kod: doświadczeni programiści często tworzą mniej kodu, o jaśniejszym celu, który jest łatwiejszy do przeczytania, testowania i modyfikacji w razie zmian wymagań. Ten artykuł przedstawia siedem konkretnych nawyków, z przykładami w JavaScript lub TypeScript przed i po ich wdrożeniu, oraz wyjaśnia konkretne korzyści, jakie każdy z nich przynosi.
Żaden z tych nawyków nie wymaga nowego narzędzia. Są to decyzje, które podejmujesz podczas pisania funkcji, nadawania nazw zmiennym, modelowania danych lub otwierania pull requesta.
1. Utrzymuj widoczny „szczęśliwy ścieżkę” za pomocą klauzul ochronnych
Załóżmy, że funkcja musi znaleźć użytkownika, potwierdzić, czy konto jest aktywne, sprawdzić, czy użytkownik ma uprawnienia, a dopiero wtedy wykonać określoną czynność. Naturalny pierwszy projekt zawiera jedno warunek wewnątrz drugiego:
function performAction(userId) {
const user = findUser(userId);
if (user) {
if (user.isActive) {
if (user.hasPermission) {
return executeAction(user);
}
}
}
return null;
}
Funkcjonuje to, ale operacja, która faktycznie ma znaczenie, znajduje się trzy poziomy niżej, a instrukcja awaryjna return null na dole jest wizualnie oddzielona od warunków, które do niej prowadzą.
Aльтernatywą jest natychmiastowe odrzucenie nieprawidłowych przypadków za pomocą klauzul ochronnych, tak aby każda niespełniona warunek wstępny znajdowała się na osobnej linii. (W tym fragmencie wcięcia nie są regularne; wszystkie cztery instrukcje po wyszukiwaniu powinny znajdować się na tym samym poziomie.)
function performAction(userId) {
const user = findUser(userId);
if (!user) return null;
if (!user.isActive) return null;
if (!user.hasPermission) return null;
return executeAction(user);
}
Teraz główna ścieżka jest jasna: sprawdzenie, sprawdzenie, sprawdzenie, działanie. To nie oznacza, że logika zagnieżdżona jest zawsze błędna. Gdy gałęzie reprezentują rzeczywiście różne wyniki, a nie jedynie warunki wstępne, struktura if/else może być najjaśniejszą formą wyrażenia. Celem jest zapobieganie temu, by kluczowa logika biznesowa została pogrzebana pod warunkami, które w rzeczywistości służą jedynie do filtrowania danych.
Nawyk: najpierw zajmować się przypadkami wyjątkowymi, a normalny bieg programu pozostawiać bez żadnych modyfikacji. Osoba uruchamiająca funkcję powinna móc zobaczyć, co zwykle się dzieje, bez konieczności śledzenia pięciu poziomów nawiasów. Dodatkową zaletą jest to, że każdy mechanizm ochronny może później zwrócić bardziej precyzyjny wynik lub błąd, co w wersji zagnieżdżonej jest trudne do zrealizowania w czysty sposób.
2. Nazywaj rzeczy tak, jakie są
Niewiele zmian poprawia czytelność tak efektywnie jak lepsze nazwy. Spójrz na tę parę linijek:
const data = getOrder();
process(data);
Czym jest data, i co robi z nią process? Trzeba przeczytać inny kod, aby się dowiedzieć. Porównaj:
const pendingOrder = getPendingOrder();
processOrder(pendingOrder);
Te same dwie linijki teraz informują o rodzaju kolejności operacji oraz o tym, co z nią się dzieje. Dobre nazwy zmniejszają wysiłek umysłowy potrzebny do zrozumienia kodu, ponieważ czytelnik nie musi już rekonstruować znaczenia na podstawie kontekstu.
Nazwy funkcji wymagają takiej samej uwagi. Ogólne czasowniki, jak ten, ukrywają ich zachowanie:
handle(order);
podczas gdy ta wersja to jasno określa:
calculateOrderTotal(order);
Podobnie, proste send nie ujawnia, co i do kogo jest wysyłane:
send(order);
a nazwa opisowa odpowiada na obie te pytania:
sendOrderConfirmation(order);
Długie nazwy nie są celem – celem są nazwy, które komunikują intencję. Korzystnym efektem ubocznym jest to, że niejasne nazwy często odnoszą się do niejasnych funkcji. Jeśli handle trudno jest przemianować, może ona wykonywać kilka niespowiązanych zadań i zasługuje na podział.
Nawyk: zanim wybierzesz nazwę, zastanów się, czy ktoś, kto przeczyta tylko tę linię, nie patrząc nigdzie indziej, zrozumie, co reprezentuje ta wartość lub funkcja. Jeśli nie, kontynuuj poszukiwania lepszej nazwy.
3. Zachowaj zewnętrzne systemy za granicą
Aplikacje komunikują się z wieloma zewnętrznymi systemami: dostawcami płatności, usługami e-mail, chmurą do przechowywania danych, dostawcami tożsamości, API firm trzecich oraz wewnętrznymi mikrosługami. Częstym błędem jest wprowadzanie formatów odpowiedzi tych systemów bezpośrednio do kodu biznesowego:
const customer = await stripeCustomer.get();
console.log(customer.email_address);
console.log(customer.customer_name);
Każda linia, która odczytuje email_address lub customer_name, jest teraz zależna od dokładnych nazw pól określonych przez dostawcę. Jeśli dostawca zmieni nazwę pola lub przebuduje swoją odpowiedź, ta zmiana wpływa na całą bazę kodu.
Rozwiązaniem jest prosta funkcja pośrednicząca, która wywołuje zewnętrzną usługę i przekształca jej odpowiedź na model używany przez aplikację:
async function getCustomer(customerId) {
const response = await externalService.getCustomer(customerId);
return {
email: response.email_address,
name: response.customer_name
};
}
Pozostała część kodu korzysta z tego wewnętrznego formatu i w ogóle nie widzi nazw używanych przez dostawcę:
const customer = await getCustomer(customerId);
console.log(customer.email);
console.log(customer.name);
Gdy zmienia się zewnętrzna API, aktualizacja wymaga jedynie mapowania wewnątrz funkcji getCustomer. To samo miejsce stanowi również naturalną lokalizację do walidacji odpowiedzi, normalizacji brakujących wartości oraz podstawiania wartości fikcyjnych podczas testów. To właśnie leży u podstaw adapterów i warstw ochronnych w większych architekturach, zastosowanych na poziomie pojedynczej funkcji.
Zwyczaj: trzymać zewnętrzną złożoność na obrzeżach. Logika biznesowa nie powinna musieć znać specyfiki każdej usługi third-party, a to rozdzielenie staje się coraz cenniejsze w miarę wzrostu liczby integracji.
4. Utrudnienie reprezentowania nieważnych stanów
Kuszącym sposobem na zdefiniowanie modelu jest uczynienie każdego pola opcjonalnym, ponieważ jest to szybkie i nigdy nie powoduje błędów kompilacji:
type Order = {
id?: string;
paymentId?: string;
status?: string;
};
Koszt pojawia się wszędzie tam, gdzie używany jest model. Każdy użytkownik musi sprawdzać to ostrożnie, ponieważ nic nie gwarantuje obecności żadnego pola:
if (order.id) {
// ...
}
if (order.paymentId) {
// ...
}
if (order.status === "paid") {
// ...
}
Głębszym problemem nie jest zbyt duża ilość informacji. Typ nie mówi nic o tym, jakie kombinacje pól są dopuszczalne. Zamówienie z identyfikatorem płatności, ale bez samej płatności, lub status "paid" bez faktycznej płatności, są wszystkie dopuszczalne z punktu widzenia kompilatora.
Bardziej precyzyjny model opisuje konkretny stan:
type PaidOrder = {
id: string;
paymentId: string;
status: "paid";
};
Wartość typu PaidOrder gwarantuje trzy rzeczy:
- ma identyfikator
- ma identyfikator płatności
- jego status to dokładnie
"paid"
Ten typ służy teraz zarówno jako dokumentacja, jak i jako zabezpieczenie w czasie kompilacji. W praktyce należałoby zdefiniować jeden typ na każdy stan – na przykład zamówienie w oczekiwaniu bez identyfikatora płatności oraz zamówienie opłacone z takim identyfikatorem – i połączyć je w związek dyskryminujący na podstawie pola status. TypeScript może wtedy automatycznie zawęzić typ po sprawdzeniu typu w postaci order.status === „paid” i ostrzec, gdy nowy stan nie jest obsłużony. Przykład zrealizowanego tego wzorca znajduje się pod adresem zastępowanie flagi ladowania związkiem dyskryminującym.
Wzorzec postępowania: nie ustawiaj domyślnie wartości w polach opcjonalnych tylko dlatego, że ułatwiają one napisanie pierwszej wersji. Zadaj sobie pytanie, które wartości rzeczywiście powinny być możliwe, i dostosuj swoje typy do nich. To nie wyeliminuje wszystkich błędów, ale znacznie utrudni stworzenie całej grupy nieprawidłowych danych.
5. Oddziel decyzje od działań
Zasady biznesowe stają się trudne do testowania, gdy są powiązane z efektami ubocznymi. Weźmy na przykład funkcję, której mogą korzystać wyłącznie zweryfikowani użytkownicy dorosli:
async function processUser(userId) {
const user = await database.getUser(userId);
if (user.isVerified && user.age >= 18) {
await database.enableFeature(userId);
await emailService.send(user.email);
}
}
Sama zasada, polegająca na weryfikacji wieku i wymogu co najmniej 18 lat, jest łączona z odczytem z bazy danych, zapisem do bazy danych oraz wysyłką e-maila. Testowanie tej zasady oznacza symulację wszystkich tych operacji.
Wyodrębnij decyzję do czystej funkcji, która przyjmuje dane i zwraca wartość logiczną:
function canUseFeature(user) {
return user.isVerified && user.age >= 18;
}
Funkcja orkiestracji pobiera następnie użytkownika, zadaje pytanie zgodnie z regułą i wykonywa efekty uboczne tylko wtedy, gdy otrzyma pozytywną odpowiedź:
async function processUser(userId) {
const user = await database.getUser(userId);
if (!canUseFeature(user)) {
return;
}
await database.enableFeature(userId);
await emailService.send(user.email);
}
Regułę można teraz testować bezpośrednio, używając zwykłego obiektu:
expect(
canUseFeature({
isVerified: true,
age: 25
})
).toBe(true);
Żadnej bazy danych, żadnego serwisu e-mail, żadnych mocków: tylko decyzja i jej dane wejściowe. Dzięki temu łatwo jest obsłużyć przypadki krawędziowe, takie jak niezweryfikowany użytkownik w wieku 30 lat lub zweryfikowany użytkownik, który ma dokładnie 18 lat.
Zwyczaj: oddzielać pytanie „czy powinniśmy to zrobić?” od pytania „jak to zrobimy?”. Dobrymi przykładami takiego podziału są:
- weryfikacja dostępu i uprawnień
- obliczanie cen
- walidacja danych wejściowych
- decydowanie o powiadomieniu kogoś
- polityki ponawiania prób
- reguły dotyczące kwalifikacji i flag funkcjonalnych
Im ważniejsza jest dana zasada dla biznesu, tym większe korzyści przynosi jej umieszczenie w małej, czystej i dobrze przetestowanej funkcji.
6. Uczyń błędy przydatnymi dla ludzi i oprogramowania
Taki błąd prawie nic nie oferuje do pracy:
throw new Error("Something went wrong");
Nie podaje, co się nie udało, gdzie, dlaczego ani czy osoba wywołująca może cokolwiek z tym zrobić.
Lepszy błąd zawiera stabilny kod wraz z wiadomością czytelną dla człowieka. Przykład zakłada klasę AppError, która rozszerza Error i przechowuje oba te wartości; nie jest to klasa wbudowana.
throw new AppError(
"PAYMENT_FAILED",
"The payment could not be completed."
);
Serializowany do odpowiedzi API, daje klientom coś przewidywalnego:
{
"code": "PAYMENT_FAILED",
"message": "The payment could not be completed."
}
Różnica, którą warto pamiętać: komunikaty są pisane dla ludzi, a kod dla systemów. Komunikaty można przepisać lub przetłumaczyć, ale wartości kodu powinny pozostać stabilne, aby klienci mogli na ich podstawie bezpiecznie podejmować decyzje. Frontend, na przykład, może zareagować na konkretny błąd:
if (error.code === "PAYMENT_FAILED") {
showPaymentError();
}
Logi wymagają takiego samego traktowania. Prosty komunikat jest trudny do wykorzystania o 3 rano:
console.log("Payment failed");
Ustrukturyzowany kontekst ułatwia wyszukiwanie i diagnozowanie informacji:
logger.error("Payment failed", {
orderId,
provider,
attempt
});
Bądź świadomy tego, co trafia do tego kontekstu. Nigdy nie rejestruj:
- hasłów
- języków dostępu
- kluczy API
- innych tajemnic
- wrażliwych danych osobowych
Jeśli potrzebujesz standardowego formatu dla ciał błędów HTTP, warto zapoznać się z szczegółami problemu RFC 9457.
Nawyk: nadaj każdemu niepowodzeniu formę, którą może odczytać człowiek, a na której może działać program. Rzucanie jest łatwą częścią; prawdziwa praca polega na tym, by niepowodzenia były przewidywalne i łatwe do zdiagnozowania.
7. Zachowuj skupienie przy każdej zmianie
Ostatni nawyk dotyczy modyfikowania kodu, a nie jego pisania. Wyobraź sobie pojedynczą prośbę o włączenie zmian, która zawiera:
- nową funkcję checkout
- migrację bazy danych
- refaktoryzację usługi płatności
- aktualizacje frontendu
- zmiany w logice ponawiania prób
- kilka niepowiązanych poprawek błędów
Nawet jeśli wszystko to działa, bardzo trudno jest dokonać rzetelnej oceny. Recenzenci przeglądają tylko pobieżnie, ryzyka ukrywają się w tym chaosie, a jeśli coś zepsuje się w produkcji, cofnięcie jednej części oznacza cofnięcie wszystkiego.
Gdy zmiany są podzielone na bardziej skoncentrowane grupy, ten sam zakres pracy wygląda tak:
PR #1 → Add checkout validation
PR #2 → Refactor payment service
PR #3 → Update checkout UI
PR #4 → Improve payment retry logic
Każdą z nich łatwiej jest:
- przeglądać
Zmiany skoncentrowane ułatwiają również współpracę: gdy pojawia się regresja, obszar do zbadania jest mały, a narzędzia takie jak git bisect wskazują na zmianę, która sama w sobie ma znaczenie.
Habitus: zanim włączysz kolejną zmianę do bieżącego zadania, zastanów się, czy dotyczy ona tego samego problemu. Jeśli nie, utwórz dla niej oddzielny commit lub pull request. Zmiana skoncentrowana jest łatwiejsza do zaufania niż duża zmiana, która przypadkowo przechodzi testy.
Wspólny mianownik: ułatwienie kolejnych zmian
Pierwszy rzut oka może sprawiać wrażenie, że te nawyki nie są ze sobą powiązane, ale mają jeden wspólny cel. Dobra inżynieria polega nie tylko na tym, by dzisiejszy kod funkcjonował, ale także na tym, by jutrzejsze modyfikacje były łatwiejsze do wprowadzenia. Każdy z tych nawyków przyczynia się do tego celu:
- widoczna, prawidłowa ścieżka ułatwia zrozumienie logiki
- znaczące nazwy sprawiają, że intencja jest oczywista
- granice wokół systemów zewnętrznych ograniczają wpływ ich zmian
- dokładne typy wykluczają całe kategorie błędów
- oddzielenie decyzji od działań umożliwia testowanie reguł biznesowych
- strukturyzowane błędy ułatwiają diagnozę awarii
- fokusowane zmiany utrzymują kod w stanie łatwym do rozwijania
Frameworki i biblioteki pojawiają się i znikają, ale te zasady mają takie samo znaczenie w JavaScript, TypeScript, Python, Java, Go czy jakimkolwiek innym języku.
Główne wnioski
- Gdy kończysz funkcję, model, integrację lub pull request, zastanów się nie tylko nad tym, czy wszystko działa, ale także nad tym, czy następny programista może to bezpiecznie zmienić.
- Klauzule ochronne, precyzyjne nazwy i dokładne typy zmniejszają ilość informacji, które czytelnik musi zapamiętać.