TypeScript ile Hata Yönetimi: catch'te unknown, Error Daraltma ve Result Deseni
catch değişkeninin neden unknown olduğunu, instanceof Error ile daraltmayı, kendi hata class'larını, Promise'lerdeki tuzakları ve hataları Result ile discriminated union olarak modellemeyi görüyoruz.
Sürüm notu: Bu yazıdaki kodlar TypeScript 7.0 ve Node 26 içindir.
Önce Tahmin Et
Bir konfigürasyon değerinden port numarası okuyan küçük bir fonksiyon. Geçersiz bir değer gelirse hata fırlatıyor, catch'te hatayı yazdırıp varsayılan porta dönüyor:
function readPort(raw: string): number {
try {
const port = Number(raw);
if (!Number.isInteger(port)) {
throw new Error(`Invalid port: ${raw}`);
}
return port;
} catch (error) {
console.log(error.message);
return 3000;
}
}
console.log(readPort("8080"));
JavaScript'te bu kodu yüzlerce kez görmüşsündür. Sence TypeScript bunu derler mi? Hata verirse hangi satırda ve neden? try bloğunda fırlatılan şeyin bir Error olduğu gözünün önünde duruyor; bunu aklında tutarak tahmin et.
JavaScript'te Her Şey Fırlatılabilir
JavaScript serilerinde gördüğümüz gibi throw yalnızca Error fırlatmak zorunda değil. String, sayı, düz bir object, hatta undefined bile fırlatılabilir:
function risky(n: number): void {
if (n === 1) throw new Error("real error");
if (n === 2) throw "just a string";
if (n === 3) throw { code: 42 };
}
Üstelik try bloğunda yalnızca senin yazdığın throw çalışmaz; çağırdığın her fonksiyon, onların çağırdığı her library de bir şey fırlatabilir. TypeScript'in catch'e ne geleceğini bilmesinin hiçbir yolu yok. Bu yüzden strict ailesinin parçası olan useUnknownInCatchVariables (TS 4.4) açıkken catch değişkeni unknown olur. Baştaki sorunun cevabı da bu:
error TS18046: 'error' is of type 'unknown'.
Hata 9. satırda, error.message'da. Biz fırlatılanın Error olduğunu görüyoruz, ama TypeScript Number(raw)'ın ya da ileride oraya eklenecek başka bir satırın ne fırlatacağını garanti edemez. Eskiden bu değişken any idi ve error.message hiçbir uyarı vermeden derlenirdi; fırlatılan bir string olduğunda da sessizce undefined yazdırırdı.
catch değişkenine annotation yazabilirsin, ama yalnızca any ya da unknown olarak. Daha dar bir tip yazmak, TypeScript'e doğrulamadığı bir şeyi söz vermek olurdu:
function risky(n: number): void {
if (n === 2) throw "just a string";
}
try {
risky(2);
} catch (error: unknown) {
if (typeof error === "string") {
console.log(error.toUpperCase());
}
}
try {
risky(2);
// Hata: Catch clause variable type annotation must be 'any' or 'unknown' if specified.
} catch (error: Error) {
console.log(error.message);
}
unknown'ı Daraltmak
unknown ile çalışmayı Temel seride ve type guard'larda gördük: önce daraltırsın, sonra kullanırsın. catch için en sık kullanılan daraltma instanceof Error'dur. Her catch'te aynı kontrolü yazmamak için küçük bir yardımcı fonksiyon işini görür:
function risky(n: number): void {
if (n === 1) throw new Error("real error");
if (n === 2) throw "just a string";
if (n === 3) throw { code: 42 };
}
function toError(value: unknown): Error {
if (value instanceof Error) {
return value;
}
return new Error(String(value));
}
for (const n of [1, 2, 3]) {
try {
risky(n);
} catch (error) {
const err = toError(error);
console.log(err.message);
}
}
Konsolda sırasıyla real error, just a string ve [object Object] görürsün. Sonuncusu, String()'in düz bir object'i nasıl çevirdiğini gösteriyor; gerçek bir projede o dalda değeri JSON.stringify ile yazdırmak ya da olduğu gibi loglamak daha faydalı olur. Önemli olan, toError'dan sonra elindeki değerin gerçekten bir Error olması ve message'ın her zaman var olması.
Kendi Hata Class'ların
Hatanın türüne göre farklı davranmak istiyorsan Error'dan türeyen kendi class'larını yazarsın. instanceof narrowing'i bu class'larla da çalışır ve sana yalnızca o class'a ait property'leri açar:
class HttpError extends Error {
readonly status: number;
constructor(status: number, message: string, options?: ErrorOptions) {
super(message, options);
this.name = "HttpError";
this.status = status;
}
}
function toError(value: unknown): Error {
if (value instanceof Error) {
return value;
}
return new Error(String(value));
}
async function loadUser(id: number): Promise<string> {
if (id === 404) {
throw new HttpError(404, `User ${id} not found`);
}
if (id === 0) {
throw "id must be positive";
}
return "Ada";
}
async function main() {
for (const id of [1, 404, 0]) {
try {
const name = await loadUser(id);
console.log("loaded:", name);
} catch (error) {
if (error instanceof HttpError) {
console.log("http:", error.status, error.message);
} else {
const err = toError(error);
console.log("other:", err.name, err.message);
}
}
}
}
main();
Çıktı loaded: Ada, http: 404 User 404 not found ve other: Error id must be positive. İlk dalda error.status'a erişebiliyoruz, çünkü TypeScript error'u HttpError'a daralttı. Class'ta parameter properties yerine açık bir status property'si yazdığımıza dikkat et; bu syntax'ın neden serinin örneklerinde yer almadığını ilerleyen bir yazıda konuşacağız.
Bir hatayı yakalayıp daha anlamlı bir hatayla yeniden fırlatırken asıl hatayı kaybetme. ES2022'deki cause seçeneği bunun için var ve TypeScript'in lib tiplerinde ErrorOptions olarak tanımlı:
function readConfig(text: string): unknown {
try {
return JSON.parse(text);
} catch (error) {
throw new Error("Config could not be parsed", { cause: error });
}
}
try {
readConfig("{ broken");
} catch (error) {
if (error instanceof Error) {
console.log(error.message);
console.log(error.cause instanceof SyntaxError);
}
}
Konsola Config could not be parsed ve true yazar. cause'un tipi de unknown; içindeki değeri kullanmadan önce onu da daraltman gerekir.
Promise'lerde İki İnce Nokta
Bir önceki yazıda async fonksiyonları tipledik. Hata tarafında iki ayrıntı sık gözden kaçar:
async function fetchName(): Promise<string> {
throw new Error("Network down");
}
async function withoutAwait(): Promise<string> {
try {
return fetchName();
} catch {
return "fallback";
}
}
async function withAwait(): Promise<string> {
try {
return await fetchName();
} catch {
return "fallback";
}
}
withAwait().then((name) => console.log("withAwait:", name));
withoutAwait()
.then((name) => console.log("withoutAwait:", name))
.catch((reason) => console.log("rejected:", reason.message));
Çıktı withAwait: fallback ve rejected: Network down. withoutAwait, promise'i await etmeden döndürdüğü için reddedilme try bloğu bittikten sonra gerçekleşir ve catch onu hiç görmez. TypeScript bu iki fonksiyonu aynı tiple kabul eder; fark tamamen çalışma zamanında.
İkinci ayrıntı son satırda: .catch callback'inin reason parametresi unknown değil, any. Promise'in lib tiplerinde böyle tanımlı ve useUnknownInCatchVariables yalnızca try/catch'i etkiler. Bu yüzden reason.message hiçbir uyarı almadan derlendi. .catch((reason: unknown) => …) yazarak aynı güvenliği kendin geri getirebilirsin.
İmza Hatayı Söylemez: Result Deseni
TypeScript'te bir fonksiyonun imzası ne fırlatabileceğini söylemez; Java'daki throws gibi bir şey yok. readPort(raw: string): number imzasına bakan biri, fonksiyonun geçersiz girdide patlayabileceğini bilemez. Beklenen başarısızlıkları dönüş tipine taşımak bu sorunu çözer. Generic tiplerde gördüğümüz Result type'ı tam bunun için:
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
type PortError =
| { kind: "empty" }
| { kind: "not-a-number"; input: string }
| { kind: "out-of-range"; value: number };
function parsePort(raw: string): Result<number, PortError> {
const text = raw.trim();
if (text === "") {
return { ok: false, error: { kind: "empty" } };
}
const port = Number(text);
if (!Number.isInteger(port)) {
return { ok: false, error: { kind: "not-a-number", input: text } };
}
if (port < 1 || port > 65535) {
return { ok: false, error: { kind: "out-of-range", value: port } };
}
return { ok: true, value: port };
}
Artık başarısızlık imzanın bir parçası. parsePort'u çağıran kişi value'ya ulaşmak için önce ok'u kontrol etmek zorunda; kontrolü atlarsa TypeScript izin vermez:
// Hata: Property 'value' does not exist on type 'Result<number, PortError>'.
console.log(parsePort("8080").value);
ok property'si discriminated union'ın ayırt edicisi. if (result.ok) dalında TypeScript result'ı başarılı tarafa daraltır ve value açılır; else dalında ise yalnızca error vardır.
Hataları Discriminated Union ile Modellemek
PortError'un kendisi de bir discriminated union. Hata bir string mesaj değil, her türün kendi verisini taşıdığı bir type. Bu sayede hatayı kullanıcıya nasıl göstereceğine çağıran taraf karar verir ve exhaustive check'le hiçbir türü unutmaz:
function describeError(error: PortError): string {
switch (error.kind) {
case "empty":
return "Port is required";
case "not-a-number":
return `"${error.input}" is not a number`;
case "out-of-range":
return `${error.value} is outside 1-65535`;
default: {
const unreachable: never = error;
return unreachable;
}
}
}
for (const raw of ["8080", "", "abc", "70000"]) {
const result = parsePort(raw);
if (result.ok) {
console.log("port:", result.value);
} else {
console.log("error:", describeError(result.error));
}
}
Çıktı port: 8080, error: Port is required, error: "abc" is not a number ve error: 70000 is outside 1-65535. Yarın PortError'a { kind: "reserved"; value: number } diye yeni bir tür eklersen, default dalındaki satır derlenmez:
error TS2322: Type '{ kind: "reserved"; value: number; }' is not assignable to type 'never'.
Yani yeni bir hata türü eklemek, onu ele alman gereken her yeri derleyiciye buldurmak demek.
throw mu, Result mı?
İkisi rakip değil, farklı işler görür. Beklenen ve çağıranın karar vermesi gereken durumlar için Result kullan: kullanıcı girdisi, form doğrulama, bulunamayan bir kayıt. Bunlar programın normal akışının parçası. Beklenmeyen durumlar için throw kullan: bozuk bir konfigürasyon, kapanan bir bağlantı, "buraya asla gelinmemeli" dediğin bir dal. Bunları her katmanda tek tek taşımak yerine üst seviyedeki bir try/catch'in yakalaması daha temizdir.
Sınırda ikisi buluşur: JSON.parse gibi fırlatan bir API'yi kendi kodunda try/catch ile sarıp Result döndüren bir fonksiyona çevirebilirsin. Dışarıdan gelen hatalar böylece senin tip sistemine girmiş olur.
Sık Düşülen Tuzaklar
catch (error)'dan sonra (error as Error).message yazmak. Derleyiciyi susturur ama hiçbir şeyi kontrol etmez; fırlatılan bir string olduğunda yine undefined alırsın. Assertion yerine instanceof ya da toError kullan.
catch (error: any) yazarak eski davranışa dönmek. Geçerli bir syntax, ama tam olarak useUnknownInCatchVariables'ın kapatmaya çalıştığı açığı geri açar.
Promise .catch callback'inin any olduğunu unutmak ve async fonksiyonda return await yerine yalnızca return yazıp catch'in çalışacağını sanmak.
Result döndüren bir fonksiyonun içinden yine de throw etmek. Çağıran taraf artık hata beklemez; iki mekanizmayı aynı fonksiyonda karıştırma.
Küçük Bir Deneme
★☆☆ Yazının başındaki readPort'u düzelt: catch'te önce error instanceof Error kontrolü yap, değilse String(error) yazdır. npx tsc --noEmit ile hatanın kaybolduğunu gör.
★★☆ Tahmin et, sonra dene: if (result.ok === false) { console.log(result.error.kind); } derlenir mi? Peki if (!result.ok) dalında result.value yazarsan ne olur?
★★☆ PortError'a { kind: "reserved"; value: number } türünü ekle ve 1024'ün altındaki portlar için parsePort'un bu hatayı döndürmesini sağla. Derleyicinin seni describeError'a nasıl götürdüğünü izle.
★★★ JSON.parse'ı saran ve Result<unknown, string> döndüren bir safeParse fonksiyonu yaz. İçeride try/catch kullan, ama dışarıya hiçbir zaman throw sızdırma.
PortError'daki kind değerleri gibi sabit bir değer kümesini modellemenin TypeScript'te bir yolu daha var ve başka dillerden gelenler ona hemen uzanır: enum. Bir sonraki yazıda enum'ları, namespace'leri ve parameter properties'i, yani TypeScript'e özgü ve çalışma zamanına kod bırakan syntax'ları ele alacağız; neden Node'da doğrudan çalışmadıklarını ve yerlerine ne kullanabileceğini göreceğiz.