Mert Özen Her Satırda Daha İleri
Backend Node.js Temelleri — #05

Node.js'te TypeScript: Native Type Stripping, erasableSyntaxOnly ve Build'siz Geliştirme

Mert Özen 14 Kas 2026 10 dk 7 görüntülenme
Node.js'te TypeScript: Native Type Stripping, erasableSyntaxOnly ve Build'siz Geliştirme

Node.js 26, .ts dosyalarını tipleri boşlukla silerek build adımı olmadan çalıştırır ama tipleri kontrol etmez; bu yüzden tsc --noEmit ayrı ve zorunlu bir adım olmalıdır. Gerçek çıktılarla tip hatalı bir dosyanın 699.8 yazdırması, enum ve parameter property hataları, import type zorunluluğu, Node'un önerdiği tsconfig, Bookstore API'sinin TypeScript 7'ye geçişi, paths yerine imports, node_modules'taki .ts yasağı ve build'in yine gerektiği durumlar.

Sürüm notu: Bu yazıdaki örnekler Node.js 26.13.1 (LTS), TypeScript 7.0.2 ve @types/node 26.6.6 ile çalıştırılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) API'sini bu yazıda TypeScript'e çeviriyoruz. Önce küçük bir dosya. Book tipinde price bir number, ama biri fiyatı string olarak yazmış:

interface Book {
    isbn: string;
    title: string;
    price: number;
}

const dune: Book = { isbn: "9780441172719", title: "Dune", price: "349.90" };
console.log(`Two copies of ${dune.title}: ${dune.price * 2}`);

Node 26 .ts dosyalarını doğrudan çalıştırabiliyor. Bu dosyayı node guess.ts ile çalıştırınca ne olur: Node tip hatasını yakalayıp durur mu, yoksa çalıştırır mı? Çalıştırırsa çıktı ne olur?

node labs/05/guess.ts
Two copies of Dune: 699.8

Çalıştırdı, hem de doğru görünen bir sonuçla: JavaScript "349.90" * 2'yi sayıya çevirip 699.8 buldu. Tip hatası ancak TypeScript derleyicisine sorunca ortaya çıkıyor:

npx tsc -p labs/05
labs/05/guess.ts(7,60): error TS2322: Type 'string' is not assignable to type 'number'.

Bu yazının ana fikri bu iki çıktıda: Node'un TypeScript desteği tipleri siler, kontrol etmez. Kodu build adımı olmadan çalıştırmak büyük bir kolaylık, ama tip kontrolünü ayrı ve zorunlu bir adım olarak kurmazsan TypeScript'in sana verdiği güvenceyi kaybedersin. TypeScript Temel serisinin "TypeScript Kurulumu" yazısında node file.ts'i tek dosyada görmüştük; burada aynı yaklaşımı bir sunucu projesine uyguluyoruz.

Type Stripping Nasıl Çalışır?

Node, bir .ts dosyasını yüklerken tip annotation'larını, interface'leri, type tanımlarını ve import type satırlarını boşlukla değiştirir, geri kalanı JavaScript olarak çalıştırır. Dönüştürme yok, polyfill yok, tsconfig.json okunmuyor. Özelliğin geçmişi Node dokümanında şöyle: 22.6'da bayrakla geldi, 22.18 ve 23.6'da varsayılan olarak açıldı, 24.12 ve 25.2'de stable oldu. Node 26'da ise enum gibi syntax'ları dönüştüren --experimental-transform-types bayrağı kaldırıldı:

node --experimental-transform-types order-status-enum.ts
node: bad option: --experimental-transform-types

Tiplerin silinip yerine boşluk konması küçük ama önemli bir ayrıntı: satır ve sütun numaraları değişmiyor. Bu yüzden Node source map üretmiyor ve stack trace doğrudan .ts dosyasındaki yeri gösteriyor:

interface OrderItem {
    isbn: string;
    quantity: number;
}

function checkQuantity(item: OrderItem): void {
    if (item.quantity < 1) {
        throw new RangeError(`Quantity must be at least 1 for ${item.isbn}`);
    }
}

checkQuantity({ isbn: "9780141439600", quantity: 0 });
RangeError: Quantity must be at least 1 for 9780141439600
    at checkQuantity (file:///home/user/bookstore-api/labs/05/stack.ts:8:15)
    at file:///home/user/bookstore-api/labs/05/stack.ts:12:1

Hata 8. satırın 15. sütununda, yani dosyada throw'un olduğu yerde. Module sistemi de JavaScript'teki gibi belirlenir: .ts dosyası en yakın package.json'daki type'a göre yüklenir, .mts her zaman ESM (ECMAScript module; 03'te gördüğümüz standart module sistemi), .cts her zaman CommonJS. .tsx desteklenmiyor.

Desteklenmeyen Syntax: enum, Parameter Property, namespace ve Decorator

Type stripping yalnız silebileceği şeyleri destekliyor. Çalışması için JavaScript kodu üretilmesi gereken TypeScript özellikleri hata verir. En sık karşılaşacağın ikisi:

enum OrderStatus {
    Pending,
    Paid,
    Shipped,
}

console.log(OrderStatus.Paid);
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
class BookService {
    constructor(private readonly currency: string) {}

    format(amount: number): string {
        return `${amount.toFixed(2)} ${this.currency}`;
    }
}

console.log(new BookService("TRY").format(349.9));
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only mode

C#'tan gelen biri için bu ikisi acı veriyor; primary constructor alışkanlığı tam olarak parameter property'ye denk geliyor. Aynı gruba runtime kodu içeren namespace'ler ve import x = require() biçimindeki import alias'ları da giriyor. Yalnız tip içeren namespace sorunsuz çalışır. Decorator'larda durum farklı: TC39'da (JavaScript'i geliştiren Ecma komitesi) hâlâ öneri aşamasında oldukları için Node onları tanımıyor bile ve sıradan bir syntax hatası veriyor:

file:///home/user/bookstore-api/labs/05/decorator.ts:3
    @logged
    ^

SyntaxError: Invalid or unexpected token

Enum'un erasable karşılığı bir as const object ve ondan türetilen union tip. Hem çalışırken bir değer hem de derlerken bir tip olarak kullanılabiliyor:

// Erasable alternatif: const object ve union tip
export const OrderStatus = {
    Pending: "Pending",
    Paid: "Paid",
    Shipped: "Shipped",
} as const;
export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];

const status: OrderStatus = OrderStatus.Paid;
console.log(status);
Paid

Bu syntax'ların ayrıntısını ve neden erasable olmadıklarını TypeScript Orta serisinin "Erasable Olmayan Syntax" yazısında anlattık. Önemli olan, bunları Node'u çalıştırınca değil, editörde ve CI'da yakalamak. Bunu erasableSyntaxOnly ayarı yapıyor; birazdan göreceğiz.

import type ve verbatimModuleSyntax

Node yalnız tek bir dosyaya bakarak tipleri siliyor; import ettiğin bir adın tip mi değer mi olduğunu bilemez. Bu yüzden tip import'larını type ile işaretlemek zorunlu:

import { Book, describe } from "./book-types.ts";

const hobbit: Book = { isbn: "9780547928227", title: "The Hobbit" };
console.log(describe(hobbit));
SyntaxError: The requested module './book-types.ts' does not provide an export named 'Book'

Book bir interface; silindiği için çalışma anında öyle bir export yok. Doğrusu import { type Book, describe } ya da ayrı bir import type satırı. tsconfig'te verbatimModuleSyntax açıksa tsc bunu önceden söyler: error TS1484: 'Book' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.

tsconfig: Node'un Önerdiği Ayarlar

Node dokümanı type stripping ile kullanılmak üzere birkaç ayar öneriyor. Bookstore API'sinin tsconfig.json'u bunların üstüne iki satır ekliyor:

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "noEmit": true,
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "resolveJsonModule": true,
    "types": ["node"]
  },
  "include": ["src", "packages/*/src"]
}

module: nodenext, TypeScript'in Node'un çözümleme kurallarını (uzantı zorunluluğu, exports, imports) birebir uygulamasını sağlıyor. noEmit ile tsc hiçbir dosya üretmiyor, yalnız kontrol ediyor; çalıştıran zaten Node. rewriteRelativeImportExtensions relative import'larda .ts uzantısına izin veriyor. erasableSyntaxOnly enum ve parameter property'yi derleme hatası yapıyor; verbatimModuleSyntax da import type'ı zorunlu kılıyor. Eklediğim iki satırdan resolveJsonModule JSON import'u için. types ise TypeScript 6 ile değişen bir varsayılan yüzünden gerekli: artık @types paketleri kendiliğinden yüklenmiyor, varsayılan boş liste. strict'i yazmadım, çünkü TypeScript 6'dan beri varsayılan olarak açık; bu varsayılanları TypeScript Temel serisinin tsconfig yazısında ayrıntılı gördük.

Laboratuvar klasöründeki dosyaları bu ayarlarla kontrol edince Node'un çalışma anında verdiği hataların hepsi derleme anında görünüyor:

labs/05/guess.ts(7,60): error TS2322: Type 'string' is not assignable to type 'number'.
labs/05/missing-type-keyword.ts(1,10): error TS1484: 'Book' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.
labs/05/order-status-enum.ts(1,6): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
labs/05/param-props.ts(2,17): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.

Bookstore API'sini TypeScript'e Çevirmek

Paketler devDependency olarak geliyor: TypeScript 7.0.2 ve Node 26'nın API'lerini tanımlayan @types/node 26.6.6. TypeScript 7'nin derleyicisi native bir binary; npm, işletim sistemine uygun paketi optional dependency olarak seçiyor.

npm install -D typescript@7.0.2 @types/node@26.6.6

Önce domain tipleri. Bunlar yalnız tip olduğu için çalışma anında hiçbir iz bırakmıyorlar:

export type Currency = "TRY" | "EUR";

export interface Book {
    isbn: string;
    title: string;
    author: string;
    price: number;
    currency: Currency;
}
import type { OrderStatus } from "@bookstore/contracts";
import type { Book } from "./book.ts";

export interface OrderItem {
    isbn: Book["isbn"];
    quantity: number;
    unitPrice: number;
}

export interface Order {
    id: string;
    customerId: string;
    status: OrderStatus;
    items: OrderItem[];
    createdAtUtc: string;
}

export function orderTotal(order: Order): number {
    return order.items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0);
}

OrderStatus tipi workspace paketinden geliyor. @bookstore/contracts'ı da .ts'e çevirdim; sipariş durumları artık enum yerine as const bir array ve ondan türetilen bir union:

export const ORDER_STATUSES = ["Pending", "Paid", "Shipped", "Delivered", "Cancelled"] as const;
export type OrderStatus = (typeof ORDER_STATUSES)[number];

Dosyalar .ts olunca package.json'da değişen satırlar şunlar. 03'te kurduğumuz imports haritasında yalnız uzantı değişti; koddaki #routes/health gibi import'lar olduğu gibi kaldı:

"main": "src/server.ts",
"imports": {
  "#routes/*": "./src/routes/*.ts",
  "#services/*": "./src/services/*.ts",
  "#domain/*": "./src/domain/*.ts",
  "#db/*": "./src/db/*"
},
"scripts": {
  "start": "node src/server.ts",
  "dev": "node --watch --env-file-if-exists=.env src/server.ts",
  "typecheck": "tsc",
  "versions": "node -p process.versions.node"
}

Route ve service'ler tip kazandı; node:http'nin tipleri import type ile geliyor:

// SQL Server yazısına kadar bellekte duran katalog; JSON dosyası başlangıçta bir kez yüklenir
import type { Book } from "#domain/book";
import books from "#db/books.json" with { type: "json" };

const catalog: readonly Book[] = books as Book[];

export function listBooks(): readonly Book[] {
    return catalog;
}

export function findBook(isbn: string): Book | undefined {
    return catalog.find((b) => b.isbn === isbn);
}
import type { IncomingMessage, ServerResponse } from "node:http";
import { toBookSummary } from "@bookstore/contracts";
import { listBooks } from "#services/bookService";

// GET /api/books
export function handleListBooks(req: IncomingMessage, res: ServerResponse): void {
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify(listBooks().map(toBookSummary)));
}
npm run typecheck
node src/server.ts
> bookstore-api@0.1.0 typecheck
> tsc
Bookstore API listening on http://localhost:3000 (development)

tsc hiçbir şey yazmadan bitti, yani hata yok; TypeScript 7'nin native derleyicisiyle bu proje için yaklaşık 0,3 saniye sürdü. Kontrolün gerçekten çalıştığını görmek için route'ta bilerek price.amount.toUpperCase() yazdım:

src/routes/books.ts(8,81): error TS2339: Property 'toUpperCase' does not exist on type 'number'.

CI'da npm run typecheck, testlerden önce çalışan ayrı bir adım olmalı. Yerelde de editörün aynı tsconfig ile çalıştığından emin ol; Node'un sessizce çalıştırdığı bir tip hatasını ancak böyle erken görürsün.

paths Yerine imports, node_modules'ta .ts Yok

Node tsconfig.json'u okumadığı için compilerOptions.paths ile tanımlanan alias'lar çalışma anında çözülmez; TypeScript derler, Node ERR_MODULE_NOT_FOUND verir. Node dokümanı da en yakın karşılık olarak # ile başlayan subpath import'ları gösteriyor. 03'te bu yüzden imports alanını seçtik: hem Node hem tsc aynı haritayı okuyor, iki ayrı yerde alias tanımlamak gerekmiyor.

İkinci sınır node_modules. Node, paket yazarlarını TypeScript kaynağı yayınlamaktan caydırmak için node_modules altındaki .ts dosyalarını çalıştırmayı reddediyor. Peki workspace paketimiz nasıl çalıştı? npm onu node_modules'a symlink olarak bağlıyor; Node da varsayılan olarak symlink'in gerçek yolunu kullanıyor, yani dosyayı packages/contracts/src/index.ts olarak görüyor. Symlink yolunu korumasını söylersek kural devreye giriyor:

node --preserve-symlinks src/server.ts
Error [ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING]: Stripping types is currently unsupported for files under node_modules, for "file:///home/user/bookstore-api/node_modules/@bookstore/contracts/src/index.ts"
  code: 'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING'

Aynı repo içindeki workspace'ler için bu bir sorun değil. Ama @bookstore/contracts'ı bir gün npm registry'ye yayınlarsan, kullanıcıların onu node_modules'tan yükleyeceği için derlenmiş .js ve .d.ts dosyaları yayınlaman gerekir.

Ne Zaman Yine de Build Gerekir?

Type stripping uygulama kodu için yeterli, ama dört durumda derleyici ya da ayrı bir araç gerekiyor:

  • Yayınlanan paketler: node_modules kuralı yüzünden tsc ile .js ve tip tanımı üretilir; rewriteRelativeImportExtensions bu sırada .ts import'larını .js'e çevirir.
  • Decorator kullanan framework'ler: NestJS gibi framework'ler decorator'lara ve derleyicinin ürettiği tip metadata'sına dayandığı için tsc ile build ister.
  • Enum ya da parameter property'den vazgeçemeyen bir codebase: Node dokümanının da önerdiği gibi tsx gibi bir paketle tam TypeScript desteği alınır.
  • JSX (JavaScript içinde HTML'e benzeyen işaretleme) ya da daha eski bir JavaScript sürümüne dönüştürme: type stripping hiçbir syntax'ı dönüştürmez.

Bookstore API'si bunların hiçbirine girmiyor, bu yüzden seri boyunca build'siz devam ediyoruz. Container image'ında da .ts dosyalarını olduğu gibi kopyalayıp node src/server.ts ile başlatmak yeterli; TypeScript derleyicisi yalnız CI'daki kontrol adımında lazım.

Üretimde Dikkat

Tip kontrolü olmadan deploy etme. Node bir tip hatası olan kodu memnuniyetle çalıştırır; pipeline'da npm run typecheck başarısız olunca deploy durmalı.

Type stripping'in de bir maliyeti var: Node her .ts dosyasını yüklerken WebAssembly ile çalışan bir parser'dan geçiriyor. Bu ek iş yalnız module'ler ilk yüklenirken yapılır, request başına değil. Açılış süresi senin için kritikse .js ile .ts arasındaki farkı kendi projende ölç.

@types/node'un major sürümünü çalıştırdığın Node'un major sürümüyle eşit tut. 26'da çalışıp 24'ün tiplerini kullanmak, yeni API'leri görmemene ya da kaldırılmış bir API'yi kullanmana yol açar.

Production image'ına TypeScript'i koyma. Derleyici, tipler ve tsconfig.json devDependency tarafında; npm ci --omit=dev ile kurulan image'da yoklar ve olmaları da gerekmiyor.

Sık Düşülen Tuzaklar

Type stripping'i type check sanmak. Önce Tahmin Et'teki "349.90" * 2 sessizce 699.8 üretti; aynı hata bir toplamda string birleştirmeye dönüşebilirdi.

.ts uzantılı import'u unutmak. Node'da relative import'ta Node'un okuyacağı dosyanın adını yazarsın: ./book.ts. ./book.js yazarsan dosya bulunamaz.

import type yazmamak. Tip olan bir adı normal import ile almak çalışma anında does not provide an export named hatası verir; verbatimModuleSyntax'ı açık tut.

tsconfig paths'e güvenmek. tsc mutlu olur, Node dosyayı bulamaz. Alias'ları package.json'daki imports'ta tanımla.

Tip tanımında enum'a geri dönmek. Alışkanlıkla yazılan tek bir enum, process'in açılışta çökmesine yeter; erasableSyntaxOnly bunu derleme aşamasında yakalar.

Küçük Bir Deneme

★☆☆ labs/05/param-props.ts'i parameter property kullanmadan, ayrı bir field ve constructor ataması ile yeniden yaz ve hem node hem npx tsc -p labs/05 ile dene.

★★☆ src/domain/order.ts'teki orderTotal'ı kullanan bir GET /api/orders/sample endpoint'i ekle. Bilerek yanlış tipte bir OrderItem yaz ve hatanın hangi adımda yakalandığını gözle.

★★★ @bookstore/contracts'ı yayınlanabilir hâle getir: tsc ile dist/ klasörüne .js ve .d.ts üreten ayrı bir tsconfig.build.json yaz, exports'a types ve default koşullarını ekle ve --preserve-symlinks ile çalıştırınca hatanın kaybolduğunu göster.

Kod tipli ve build'siz çalışıyor. Bir sonraki yazıda sunucunun kalbine iniyoruz: libuv phase'leri, process.nextTick ile setImmediate arasındaki fark, hangi işin thread pool'a gittiği ve Bookstore'un fiyat raporunu event loop'u bloklamadan worker thread'e taşımak.