← Writings
·

Types Are Not Tests (But They Are Close)

A strong type system catches an entire class of bugs at compile time. Understanding what it cannot catch is just as important as understanding what it can.

When TypeScript was gaining adoption, one argument made the rounds: if you have good types, you need fewer tests. This argument has a core of truth that is buried under enough imprecision to cause harm.

Types and tests are not substitutes for each other. They catch different things. Understanding the boundary between them is one of the more practically useful things a TypeScript engineer can internalize.

What Types Actually Prove

A type annotation is a claim about shape. When you write user: User, you are asserting that whatever occupies that slot in memory will have the fields and methods defined by the User type. The compiler enforces this claim statically — without running the code.

This is powerful. An entire class of bugs — passing the wrong thing to a function, accessing a property that does not exist, forgetting a nullable — becomes impossible. Not unlikely. Impossible. The program will not compile.

type User = {
  id: string;
  email: string;
  role: 'admin' | 'viewer';
};

// This is a compile error, not a runtime error
function sendEmail(user: User) {
  console.log(user.emailAddress); // Property 'emailAddress' does not exist
}

The compiler caught a bug without a test, without a staging environment, without a customer. That is real value.

What Types Cannot Prove

Types prove shape. They do not prove behavior.

A function typed as (n: number) => number will accept any function that takes a number and returns a number. It will accept a function that doubles its input. It will also accept a function that returns 0 for every input, or one that corrupts a global variable as a side effect. The type is satisfied. The behavior may be completely wrong.

// Both satisfy the type signature
const double = (n: number): number => n * 2;
const broken = (n: number): number => 0; // Always returns 0

// The type system cannot distinguish these
function applyTax(amount: number, taxFn: (n: number) => number): number {
  return taxFn(amount);
}

This is where tests are irreplaceable. A test says: given these inputs, I expect these outputs. It describes behavior, not shape. No type system can express “this function correctly calculates tax under all the edge cases our business logic defines.”

A Venn diagram showing types catching shape errors and tests catching behavior errors, with a small overlap in the middle
Types and tests cover different territory — the overlap is smaller than most engineers assume

The Practical Division of Labor

A useful mental model: types handle the what, tests handle the whether.

Types answer: what shape does this data have? What are the valid values for this field? What does this function accept and return? These are structural questions with structural answers.

Tests answer: does this function behave correctly when the input is negative? What happens if the list is empty? Does the authentication middleware correctly reject expired tokens? These are behavioral questions that require execution to answer.

The strongest codebases use both aggressively. Types eliminate an entire class of bugs so cheaply that there is no reason not to. Tests cover the behavioral surface that types cannot reach. The mistake is using one as an excuse to skip the other.

Making Types Do More Work

Most TypeScript codebases use types at a fraction of their expressive power. A string where a more precise type would work is a missed opportunity.

Consider IDs. In a system with users, orders, and products, the IDs are all strings. But a user ID and a product ID are not interchangeable — passing one where the other is expected is a bug. Branded types make this a compile error:

type UserId = string & { readonly _brand: 'UserId' };
type OrderId = string & { readonly _brand: 'OrderId' };

function createUserId(id: string): UserId {
  return id as UserId;
}

function getOrder(id: OrderId) { /* ... */ }

const userId = createUserId('usr_123');

// Compile error: Argument of type 'UserId' is not assignable to parameter of type 'OrderId'
getOrder(userId);

This requires no runtime overhead. It is pure static analysis. And it prevents an entire class of ID-confusion bugs that would otherwise be caught only by tests, or not at all.

Diagram showing how branded types create distinct categories from the same underlying primitive type
Branded types give the same primitive different identities at the type level

The Test That Types Cannot Replace

There is one category of test that no type system, however expressive, can replace: tests that describe business rules.

A tax calculation that returns a number is well-typed. But the type does not know that the rate for enterprise customers is 0% for the first sixty days after onboarding. That rule lives in a product document, in someone’s memory, or in a test. If it lives only in someone’s memory, it will eventually be wrong.

Tests of business logic are executable documentation. They encode the why, not just the what. They are the specification that the implementation must satisfy. A type annotation tells the next developer what a function accepts. A well-named test tells them why it exists.

describe('tax calculation', () => {
  it('charges 0% for enterprise customers within 60-day onboarding window', () => {
    const customer = makeEnterpriseCustomer({ daysSinceOnboarding: 45 });
    expect(calculateTax(customer, 1000)).toBe(0);
  });

  it('resumes standard rate after onboarding window closes', () => {
    const customer = makeEnterpriseCustomer({ daysSinceOnboarding: 61 });
    expect(calculateTax(customer, 1000)).toBe(80); // 8% standard
  });
});

No type can say this. Only a test can.


Use types to make invalid states unrepresentable. Use tests to verify that valid states produce correct behavior. Neither replaces the other. Together, they come close to reliable.

Ketika TypeScript mulai diadopsi, satu argumen beredar: jika kamu punya type yang bagus, kamu butuh lebih sedikit test. Argumen ini memiliki inti kebenaran yang terkubur di bawah cukup banyak ketidaktepatan sehingga bisa menimbulkan masalah.

Type dan test bukan pengganti satu sama lain. Mereka menangkap hal yang berbeda. Memahami batas di antara keduanya adalah salah satu hal yang paling praktis berguna yang bisa diinternalisasi oleh seorang engineer TypeScript.

Apa yang Sebenarnya Dibuktikan Type

Anotasi type adalah klaim tentang bentuk. Ketika kamu menulis user: User, kamu menyatakan bahwa apapun yang menempati slot itu dalam memori akan memiliki field dan metode yang didefinisikan oleh type User. Compiler menegakkan klaim ini secara statis — tanpa menjalankan kode.

Ini kuat. Seluruh kelas bug — melewatkan hal yang salah ke fungsi, mengakses properti yang tidak ada, melupakan nullable — menjadi tidak mungkin. Bukan tidak mungkin. Tidak mungkin. Program tidak akan dikompilasi.

type User = {
  id: string;
  email: string;
  role: 'admin' | 'viewer';
};

// Ini adalah compile error, bukan runtime error
function sendEmail(user: User) {
  console.log(user.emailAddress); // Property 'emailAddress' does not exist
}

Compiler menemukan bug tanpa test, tanpa staging environment, tanpa pelanggan. Itu nilai nyata.

Apa yang Tidak Bisa Dibuktikan Type

Type membuktikan bentuk. Mereka tidak membuktikan perilaku.

Fungsi yang ditipe sebagai (n: number) => number akan menerima fungsi apapun yang mengambil angka dan mengembalikan angka. Ia akan menerima fungsi yang menggandakan inputnya. Ia juga akan menerima fungsi yang mengembalikan 0 untuk setiap input, atau yang merusak variabel global sebagai efek samping. Tipenya terpenuhi. Perilakunya mungkin sepenuhnya salah.

// Keduanya memenuhi tipe signature
const double = (n: number): number => n * 2;
const broken = (n: number): number => 0; // Selalu mengembalikan 0

// Sistem type tidak bisa membedakan keduanya
function applyTax(amount: number, taxFn: (n: number) => number): number {
  return taxFn(amount);
}

Di sinilah test tidak tergantikan. Test mengatakan: diberikan input ini, saya mengharapkan output ini. Ia menggambarkan perilaku, bukan bentuk. Tidak ada sistem type yang bisa mengekspresikan “fungsi ini menghitung pajak dengan benar di bawah semua kasus tepi yang didefinisikan oleh logika bisnis kita.”

Diagram Venn yang menunjukkan type menangkap error bentuk dan test menangkap error perilaku
Type dan test mencakup wilayah yang berbeda — tumpangannya lebih kecil dari yang diasumsikan kebanyakan engineer

Pembagian Kerja yang Praktis

Model mental yang berguna: type menangani apa, test menangani apakah.

Type menjawab: bentuk apa yang dimiliki data ini? Apa nilai-nilai valid untuk field ini? Apa yang diterima dan dikembalikan fungsi ini? Ini adalah pertanyaan struktural dengan jawaban struktural.

Test menjawab: apakah fungsi ini berperilaku benar ketika inputnya negatif? Apa yang terjadi jika listnya kosong? Apakah middleware autentikasi dengan benar menolak token yang kedaluwarsa? Ini adalah pertanyaan perilaku yang memerlukan eksekusi untuk dijawab.

Codebase yang paling kuat menggunakan keduanya secara agresif. Type menghilangkan seluruh kelas bug dengan sangat murah sehingga tidak ada alasan untuk tidak melakukannya. Test mencakup permukaan perilaku yang tidak bisa dijangkau oleh type. Kesalahannya adalah menggunakan satu sebagai alasan untuk melewatkan yang lain.

Membuat Type Bekerja Lebih Keras

Kebanyakan codebase TypeScript menggunakan type pada sebagian kecil dari kekuatan ekspresifnya. string di mana type yang lebih tepat akan bekerja adalah peluang yang terlewat.

Pertimbangkan ID. Dalam sistem dengan pengguna, pesanan, dan produk, ID-nya semuanya string. Tapi ID pengguna dan ID produk tidak bisa dipertukarkan — melewatkan satu di mana yang lain diharapkan adalah bug. Branded type menjadikan ini compile error:

type UserId = string & { readonly _brand: 'UserId' };
type OrderId = string & { readonly _brand: 'OrderId' };

function createUserId(id: string): UserId {
  return id as UserId;
}

function getOrder(id: OrderId) { /* ... */ }

const userId = createUserId('usr_123');

// Compile error: Argument of type 'UserId' is not assignable to parameter of type 'OrderId'
getOrder(userId);

Ini tidak memerlukan overhead runtime apapun. Ini adalah analisis statis murni. Dan ia mencegah seluruh kelas bug kebingungan ID yang sebaliknya hanya akan tertangkap oleh test, atau tidak sama sekali.

Diagram yang menunjukkan bagaimana branded type membuat kategori yang berbeda dari tipe primitif yang sama
Branded type memberi identitas yang berbeda pada primitif yang sama di level type

Test yang Tidak Bisa Digantikan Type

Ada satu kategori test yang tidak bisa digantikan oleh sistem type manapun, betapapun ekspresifnya: test yang menggambarkan aturan bisnis.

Kalkulasi pajak yang mengembalikan number sudah ditype dengan baik. Tapi tipenya tidak tahu bahwa tarif untuk pelanggan enterprise adalah 0% untuk enam puluh hari pertama setelah onboarding. Aturan itu hidup dalam dokumen produk, dalam ingatan seseorang, atau dalam test. Jika ia hanya hidup dalam ingatan seseorang, itu pada akhirnya akan salah.

Test logika bisnis adalah dokumentasi yang dapat dieksekusi. Mereka mengkodekan mengapa, bukan hanya apa. Mereka adalah spesifikasi yang harus dipenuhi oleh implementasi. Anotasi type memberi tahu developer berikutnya apa yang diterima fungsi. Test yang dinamai dengan baik memberi tahu mereka mengapa ia ada.

describe('tax calculation', () => {
  it('charges 0% for enterprise customers within 60-day onboarding window', () => {
    const customer = makeEnterpriseCustomer({ daysSinceOnboarding: 45 });
    expect(calculateTax(customer, 1000)).toBe(0);
  });

  it('resumes standard rate after onboarding window closes', () => {
    const customer = makeEnterpriseCustomer({ daysSinceOnboarding: 61 });
    expect(calculateTax(customer, 1000)).toBe(80); // 8% standar
  });
});

Tidak ada type yang bisa mengatakan ini. Hanya test yang bisa.


Gunakan type untuk membuat state yang tidak valid tidak bisa direpresentasikan. Gunakan test untuk memverifikasi bahwa state yang valid menghasilkan perilaku yang benar. Keduanya tidak menggantikan satu sama lain. Bersama-sama, mereka mendekati keandalan.