Bu blogun markdown'ı yok. Yazılar TypeScript dosyası ve içerik bir `Block[]` dizisi. Kulağa fazladan iş gibi geliyor; pratikte tam tersi oldu — yeni bir blok tipi eklediğimde onu render etmeyi unutmam mümkün değil, çünkü derleme kırılıyor.
Bu yazıda TypeScript'in React tarafında gerçekten fark yaratan üç aracını, bu sitenin kendi kodundan örneklerle anlatacağım: discriminated union, `as const` ve tam kapsama kontrolü.
Discriminated Union: Ortak Alan Ayrıştırıcıdır
Bir yazının içeriğini nasıl temsil edersiniz? İlk düşünülen şey tek bir esnek nesne:
// KÖTÜ: her alan opsiyonel, hiçbiri garanti değil
interface Block {
type: string
text?: string
items?: string[]
head?: string[]
rows?: string[][]
lang?: string
}Bu tip hiçbir şey söylemiyor. `type: "table"` olan bir bloğun `rows` alanı olduğunu bilmiyorsunuz, renderer içinde her yerde `block.rows ?? []` yazmanız gerekiyor ve `type: "p"` olan bir bloğa yanlışlıkla `rows` verdiğinizde kimse itiraz etmiyor.
Discriminated union bunu tersine çeviriyor. Her varyantın kendi şekli var ve `type` alanı hangisi olduğunu söylüyor:
export type Block =
| { type: 'lead'; text: string }
| { type: 'p'; text: string }
| { type: 'h2'; text: string }
| { type: 'code'; lang: string; text: string; file?: string }
| { type: 'ul'; items: string[] }
| { type: 'quote'; text: string }
| { type: 'callout'; variant: 'tip' | 'info' | 'warning'; text: string }
| { type: 'table'; head: string[]; rows: string[][] }
| { type: 'stats'; label?: string; items: { value: string; note: string }[] }
| {
type: 'compare'
label?: string
before: { label: string; value: string }
after: { label: string; value: string }
note?: string
}
| { type: 'steps'; items: { title: string; text: string }[] }Şimdi `switch (block.type)` içinde TypeScript her dalda tipi daraltıyor. `case "table"` bloğunda `block.rows` doğrudan `string[][]`; opsiyonel değil, kontrol gerekmiyor.
function BlockRenderer({ block }: { block: Block }) {
switch (block.type) {
case 'table':
// block.head ve block.rows burada garantili
return (
<table>
<thead>
<tr>{block.head.map((c) => <th key={c}>{c}</th>)}</tr>
</thead>
<tbody>
{block.rows.map((row, i) => (
<tr key={i}>{row.map((c, j) => <td key={j}>{c}</td>)}</tr>
))}
</tbody>
</table>
)
case 'compare':
// block.before / block.after garantili, block.note opsiyonel
return <CompareCard {...block} />
}
}Kapsama Kontrolü: Unuttuğunuzu Derleyici Söylesin
Union'ın asıl faydası burada ortaya çıkıyor. Yeni bir blok tipi eklediğimde renderer'ı güncellemeyi unutursam, blok sessizce hiç çizilmiyor. Bunu bir kere yaşadım: `steps` tipini ekledim, yazıya koydum, sayfada hiçbir şey görünmedi ve on dakika neden diye baktım.
Çözüm `never` ile bir kapsama kontrolü:
function assertNever(value: never): never {
throw new Error(`Bilinmeyen blok: ${JSON.stringify(value)}`)
}
switch (block.type) {
case 'p': return <P {...block} />
case 'table': return <Table {...block} />
// ... diğer durumlar
default:
// Bütün durumlar ele alındıysa block burada 'never' tipindedir.
// Bir tip eklenip case yazılmazsa bu satır DERLEME HATASI verir.
return assertNever(block)
}İpucu
Bu desenin değeri, hatayı zamanda öne çekmesi. `default: return null` yazarsanız eksik durum çalışma zamanında sessizce kaybolur; `assertNever` ile aynı eksiklik derleme anında, dosyayı kaydettiğiniz saniye ortaya çıkar.
as const: Veriyi Tipe Çevirmek
`as const` küçük bir ek ama iki iş birden yapıyor: değerleri okunur kılıyor ve literal tipleri koruyor.
export const NAV_ITEMS = [
{ href: '/', label: 'Ana Sayfa', icon: '⌂', shortcut: 'G H' },
{ href: '/projeler', label: 'Projeler', icon: '◈', shortcut: 'G P' },
{ href: '/blog', label: 'Blog', icon: '✦', shortcut: 'G B' },
] as const
export type NavItem = (typeof NAV_ITEMS)[number]`as const` olmadan `href` alanının tipi `string` olurdu. Onunla birlikte `"/" | "/projeler" | "/blog"`. Yani bir yere `/projelerr` yazdığımda TypeScript itiraz ediyor.
İkinci satır da önemli: tipi elle yazmıyorum, veriden türetiyorum. Listeye yeni bir öğe eklediğimde tip kendiliğinden genişliyor. Bu "tek doğruluk kaynağı" fikrinin tip seviyesindeki karşılığı.
Aynı Fikrin Bir Adım Ötesi
Proje kartlarındaki mini önizlemeler bir zamanlar sıraya bağlıydı: `i === 0` ise not listesi, `i === 1` ise grafik. Proje sırasını değiştirince yanlış maket yanlış kartta çizildi.
Düzeltme, görseli veriye bağlamak oldu:
export type ProjectPreview =
| 'ticker' | 'notes' | 'chart' | 'grid' | 'board' | 'browser'
export interface Project {
title: string
preview?: ProjectPreview
/** Bu projeyi anlatan yazının slug'ı — kart ile blog birbirine bağlanıyor. */
postSlug?: string
stats?: { value: string; label: string }[]
}Artık `ProjectPreview` union'ına yeni bir değer eklediğimde, önizleme bileşenindeki `switch` onu ele almazsa yine derleme kırılıyor. Aynı desen, farklı yer.
Bir Hook'ta Yaptığım Gerçek Hata
Şimdi tipin kurtaramadığı bir hataya geleyim, çünkü TypeScript her şeyi çözmüyor.
Üç projede kullandığım bir `useLocalStorage` hook'um vardı. Üçüne de aynı bug'ı taşıdım:
// HATALI SÜRÜM
const set = (v: T | ((prev: T) => T)) => {
setValue(v)
localStorage.setItem(
key,
// 'value' closure'dan geliyor — bu render'daki eski değer.
JSON.stringify(typeof v === 'function' ? (v as (p: T) => T)(value) : v),
)
}Belirti şuydu: sepet sayacında `set(n => n + 1)` iki kere üst üste çağrılınca ekranda 2 görünüyor, sayfayı yenileyince 1 görünüyordu. React state'i doğru güncelliyor çünkü fonksiyonel güncellemeyi kuyruğa alıyor; ama `localStorage`'a yazarken `value` hâlâ o render'daki eski değer.
Tip sistemi bunu yakalayamaz — tipler doğru, mantık yanlış. Doğrusu, yazmayı state güncelleyicinin içine taşımak:
const set = useCallback((v: T | ((prev: T) => T)) => {
setValue((prev) => {
const next = typeof v === 'function' ? (v as (p: T) => T)(prev) : v
// 'prev' React'in kuyruğundaki güncel değer; closure'dan gelmiyor.
if (typeof window !== 'undefined') {
localStorage.setItem(key, JSON.stringify(next))
}
return next
})
}, [key])`typeof window !== "undefined"` kontrolü de eksikti — sadece okuma tarafında vardı. Sunucuda çağrıldığında patlıyordu.
TypeScript şekli doğrular, davranışı değil. Bir hook'un tipleri kusursuzken içindeki closure'ın yanlış anı yakalaması gayet mümkün.
type mı interface mi
Bu tartışmada kesin bir kural yok ama benim kullandığım ayrım şu:
| Durum | Seçim | Neden |
|---|---|---|
| Union / kesişim | type | interface union yazamaz |
| Bir değerden türetme | type | `typeof X[number]` sadece type ile |
| Genişletilecek nesne | interface | declaration merging gerekiyorsa |
| Bileşen props | ikisi de olur | Tutarlı olun, karıştırmayın |
Bu sitede `Block` ve `ProjectPreview` union oldukları için `type`, `Project` ve `BlogPost` düz nesne oldukları için `interface`. Kural değil, alışkanlık — ama proje içinde tutarlı.
Ne Zaman Aşırıya Kaçtım
Bir dönem her şeyi generic yazmaya çalıştım. Bir liste bileşenini üç tip parametresiyle yazdım ve altı ay sonra kendi yazdığım hata mesajını okuyamadım.
Öğrendiğim eşik şu: generic, aynı yapıyı **üç farklı tiple** kullanacaksan değer. İki kullanım varsa iki ayrı bileşen yazmak daha okunur oluyor.
İkinci eşik: bir tipi anlamak için üç dosya açmak gerekiyorsa, o tip fazla soyutlanmış demektir. `Block` union'ı uzun bir tip ama tek dosyada ve okunduğunda ne olduğu anlaşılıyor. Değeri buradan geliyor.