Mert Özen Her Satırda Daha İleri
Frontend TypeScript Orta — #11

TypeScript Module'leri: import type, verbatimModuleSyntax ve Dosya Uzantıları

Mert Özen 3 Kas 2026 8 dk 0 görüntülenme
TypeScript Module'leri: import type, verbatimModuleSyntax ve Dosya Uzantıları

TypeScript'te module ile script farkını, import type ve export type'ı, verbatimModuleSyntax'ın neden önemli olduğunu, import'larda .ts uzantısını ve bundler ile nodenext çözümlemesini örneklerle görüyoruz.

Sürüm notu: Bu yazıdaki kodlar TypeScript 7.0 ve Node 26 içindir.

Önce Tahmin Et

Bir önceki yazıda async kodu tipledik; fetch cevabını doğrulayan fonksiyonlar yazdık. Gerçek bir projede bu fonksiyonlar tek dosyada durmaz, ayrı dosyalara dağılır ve birbirini import eder. Şu iki dosyaya bak. Projenin tsconfig'inde verbatimModuleSyntax açık:

// user.ts
export interface User {
  id: number;
  name: string;
}

export function formatUser(user: User): string {
  return `#${user.id} ${user.name}`;
}

// main.ts
import { User, formatUser } from "./user.ts";

const ada: User = { id: 1, name: "Ada" };
console.log(formatUser(ada));

Kod mantık olarak kusursuz: User export edilmiş, formatUser da. Sence npx tsc bu import satırına itiraz eder mi? Ederse neden? Cevap, TypeScript'in module'leri JavaScript'e çevirirken tipleri nasıl sildiğinde saklı.

Her Dosya Ya Module Ya Script

ES module'lerin import/export syntax'ını JavaScript serilerinde gördük; TypeScript aynı syntax'ı kullanır. TypeScript'in ekstra bir ayrımı var: içinde en az bir import ya da export olan dosya bir module'dür ve kendi scope'una sahiptir. Hiç import/export içermeyen dosya ise bir script sayılır ve içindeki üst seviye tanımlar ortak, global bir scope'a düşer.

// first.ts
const count = 1;
console.log(count);

// second.ts
// Hata: Cannot redeclare block-scoped variable 'count'.
const count = 2;
console.log(count);

Bu iki dosya birbirinden habersiz ama TypeScript ikisini de script olarak görüyor ve aynı global scope'ta iki count buluyor. Dosyaya bir export eklemek ya da tsconfig'de module: "nodenext" kullanıp package.json'a "type": "module" yazmak sorunu çözer; ikinci durumda TypeScript her .ts dosyasını zaten ES module kabul eder. Bu ayrımı aklında tut, declaration dosyalarını konuşurken tekrar karşımıza çıkacak.

Tipler Silinince Import'a Ne Olur?

Temel seride tiplerin derleme sırasında silindiğini gördük. Bir module'den hem tip hem değer import ettiğinde de aynısı olur. verbatimModuleSyntax kapalıyken tsc'nin main.ts için ürettiği JavaScript çıktısı şöyle (uzantının .js'e dönmesine birazdan geleceğiz):

import { formatUser } from "./user.js";
const ada = { id: 1, name: "Ada" };
console.log(formatUser(ada));

User import'tan kaybolmuş. tsc bunu yapabiliyor, çünkü bütün projeyi görüyor: user.ts'i açıp User'ın bir interface olduğunu, çalışma zamanında karşılığı olmadığını biliyor. Buna import elision denir.

Sorun şu: kodunu her zaman tsc çalıştırmıyor. Node 26 node main.ts dediğinde dosyayı tek başına okur, tipleri siler ve çalıştırır; user.ts'e bakıp User'ın tip olduğunu anlamaya çalışmaz. Bundler'ların çoğu da dosyaları tek tek dönüştürür. Bu araçlar import { User } satırını olduğu gibi bırakırsa, çalışma zamanında var olmayan bir export istenmiş olur. Çözüm, hangi import'un tip olduğunu dosyanın kendisinde söylemek.

import type ve export type

TypeScript bunun için import ve export'a bir type işareti ekler:

// report.ts
import type { User } from "./user.ts";
import { formatUser } from "./user.ts";

export type { User };

export function report(users: readonly User[]): string {
  return users.map(formatUser).join("\n");
}

import type ile gelen her şey yalnızca tip dünyasında yaşar ve satır çıktıdan tümüyle silinir. Aynı module'den hem tip hem değer alıyorsan iki satıra bölmek zorunda değilsin; işareti tek bir isme de koyabilirsin: import { type User, formatUser } from "./user.ts". Re-export için de aynısı geçerli: export type { User } dışarıya yalnızca tipi verir.

Tip olarak import ettiğin bir şeyi değer olarak kullanmaya çalışırsan TypeScript seni durdurur:

import type { formatUser } from "./user.ts";

// Hata: 'formatUser' cannot be used as a value because it was imported using 'import type'.
formatUser({ id: 1, name: "Ada" });

// typeof tip dünyasında kalır, sorun yok
type Formatter = typeof formatUser;

Bu bir class için de geçerli: import type ile aldığın bir class'ı tip olarak annotation'da kullanabilirsin, ama new ile instance'ını oluşturamazsın.

verbatimModuleSyntax: Yazdığın Neyse O Kalır

TS 5.0 ile gelen verbatimModuleSyntax bayrağı kuralı basitleştirir: başında type olmayan her import ve export çıktıda olduğu gibi kalır, type ile işaretlenenler silinir. tsc artık başka dosyalara bakıp tahmin yürütmez. Tahmin yürütmediği için de, tip olduğu belli olan bir şeyi işaretsiz import ettiğinde hata verir. Baştaki sorunun cevabı bu:

main.ts(1,10): error TS1484: 'User' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.

Düzeltmek için import { type User, formatUser } from "./user.ts" yazman yeter. Böylece Node da, bundler da, tsc de aynı çıktıyı üretir. TS 5.9'dan beri tsc --init bu bayrağı açık getiriyor; yeni projelerde varsayılan alışkanlık bu.

İnce bir fark daha: import { type User } from "./user.ts" yazarsan çıktıda import {} from "./user.js" kalır, yani module yine yüklenir ve varsa yan etkileri çalışır. import type { User } ise satırı tamamen siler. Re-export'ta da aynı katılık geçerli:

// index.ts
// Hata: Re-exporting a type when 'verbatimModuleSyntax' is enabled requires using 'export type'.
export { User } from "./user.ts";
export { formatUser } from "./user.ts";

İlk satırı export type { User } from "./user.ts" yaptığında hata kaybolur.

Import'larda Dosya Uzantısı

Tarayıcıda ve Node'da ES module import'ları dosya uzantısını tam ister; "./user" yazıp dosyanın bulunmasını bekleyemezsin. Node 26 .ts dosyalarını doğrudan çalıştırdığında import'ta da gerçek dosya adını, yani "./user.ts"'i görmek ister. tsc ise varsayılan olarak .ts uzantılı import'a izin vermez:

// Hata: An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled.
import { formatUser } from "./user.ts";

İki ayar bu kapıyı açar. allowImportingTsExtensions yalnızca tsc hiç JavaScript üretmiyorsa (noEmit ya da emitDeclarationOnly) kullanılabilir; tsc çıktı üretseydi, "./user.ts" yazan bir .js dosyası çalışma zamanında bozulurdu. TS 5.7 ile gelen rewriteRelativeImportExtensions ise çıktı üretirken göreli import'lardaki .ts'i .js'e çevirir. Yukarıdaki derlenmiş main.js'de "./user.js" görmenin sebebi buydu. Bu bayrak açıkken .ts uzantılı import'lar ayrıca izin istemeden kabul edilir.

moduleResolution: bundler mı, nodenext mi?

moduleResolution, TypeScript'in bir import yolunu hangi dosyaya çözeceğini belirler. TS 6.0'dan beri module varsayılanı esnext ve bu durumda çözümleme bundler moduna geçer; tsc --traceResolution çıktısında bunu "Module resolution kind is not specified, using 'Bundler'." satırıyla görebilirsin. bundler modu bir bundler'ın davranışını taklit eder: import { formatUser } from "./user" gibi uzantısız yollar geçerlidir, çünkü dosyayı bulan sonradan bundler olacaktır.

module: "nodenext" ise Node'un kurallarını birebir uygular. package.json'da "type": "module" varsa dosyalar ES module'dür ve uzantısız göreli import kabul edilmez:

// Hata: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './user.js'?
import { formatUser } from "./user";

Pratik kural: kodunu Vite ya da esbuild gibi bir bundler paketleyecekse bundler, kodu Node doğrudan çalıştıracaksa ya da bir library yayımlıyorsan nodenext. İkisinin farkları (CommonJS ile birlikte çalışma, exports alanı) Node serisinin konusu; burada yalnızca hangisini ne zaman seçeceğini bilmen yeterli.

Node İçin Önerilen tsconfig

Node'un kendi dokümantasyonu, .ts dosyalarını doğrudan çalıştıracağın projeler için şu ayarları öneriyor. Bu serideki örnekler de bu kurala uyuyor:

{
  "compilerOptions": {
    "module": "nodenext",
    "target": "esnext",
    "noEmit": true,
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true
  }
}

noEmit tsc'yi yalnızca denetçi yapar; kodu Node çalıştırır. verbatimModuleSyntax type-only import'ları dosyada görünür kılar, Node'un tipleri güvenle silebilmesi için. erasableSyntaxOnly ise silinerek atılamayan TypeScript syntax'larını yasaklar; onları ilerleyen bir yazıda ayrıca ele alacağız. Yanına package.json'da "type": "module" eklediğinde iş akışı iki komuta iner:

# tip denetimi
npx tsc
# çalıştırma (tip denetimi yapmaz)
node main.ts

Sık Düşülen Tuzaklar

Birincisi "type": "module"'u unutmak. Bu tsconfig ile package.json'da type alanı yoksa dosyalar CommonJS sayılır ve tsc her import'a "ECMAScript imports and exports cannot be written in a CommonJS file under 'verbatimModuleSyntax'." (TS1295) diye itiraz eder. Mesaj uzun ama çözümü tek satır.

İkincisi tip denetiminden geçmeyen kodu Node'da çalıştırıp "çalışıyor" sanmak. Node tsconfig'i okumaz; verbatimModuleSyntax kuralını uygulayan Node değil, tsc'dir. Type-only bir import'u işaretlemeyi unutursan bunu ya npx tsc'de bir hata olarak ya da çalışma zamanında eksik bir export hatası olarak öğrenirsin. İlki çok daha ucuz.

Üçüncüsü tsconfig'deki paths gibi takma adlara güvenmek. tsc bunları anlar ama Node tsconfig'i okumadığı için çalışma zamanında çözemez. Node'da çalışacak kodda göreli yollar ya da package.json'daki imports alanı daha sağlam bir yol.

Dördüncüsü script dosyaları. Hiç import'u ya da export'u olmayan küçük bir yardımcı dosya, başka bir dosyadaki aynı isimli değişkenle çakışabilir. Böyle bir hata görürsen önce dosyanın module olup olmadığına bak.

Küçük Bir Deneme

★☆☆ Yazıdaki user.ts ve main.ts'i, önerilen tsconfig ve "type": "module" içeren bir package.json ile bir klasöre koy. npx tsc çalıştır, TS1484'ü gör. Sonra hatayı iki farklı yolla düzelt: bir kez import type ile ayrı satırda, bir kez de satır içi type ile.

★★☆ Tahmin et, sonra dene: import { type User } from "./user.ts" ile import type { User } from "./user.ts" satırlarının JavaScript çıktısı aynı mı? noEmit'i geçici olarak kaldırıp outDir vererek çıktıya kendin bak.

★★★ package.json'dan "type": "module"'u sil ve tsc'nin hangi hata kodunu vereceğini tahmin et. Ardından tsconfig'i module: "esnext" yapıp import'tan uzantıyı tamamen kaldır; bu kez neden hata almadığını --traceResolution çıktısıyla açıkla.

Fonksiyonların ve tiplerin artık dosyalar arasında düzgünce taşınıyor. Ama import ettiğin her fonksiyon her zaman başarıyla dönmez; bazıları hata fırlatır, bazıları geçersiz bir girdiyle karşılaşır. Bir sonraki yazıda hata yönetimine geçiyoruz: catch'te yakaladığın değerin neden unknown olduğunu, onu nasıl daralttığını ve hataları tip sisteminin parçası hâline getirmeyi göreceğiz.