Interface #

Interfaces are one of the most important and most frequently used constructs in TypeScript. They define a “shape” — the structure a value must have to be compatible with that type. Unlike classes that produce JavaScript code, interfaces exist purely at the TypeScript level and disappear entirely after compilation. This makes them very lightweight: no runtime overhead, no extra code in the output, only type information the compiler uses for validation. Understanding interfaces means understanding how TypeScript implements structural typing — a type system where compatibility is determined by shape, not by a type’s name or origin.

Interface vs Type Alias — When to Choose Which? #

Before diving into interface details, it’s important to understand the difference from type aliases — because the two can often be used interchangeably, and many developers are confused about which to choose:

// Defining an object shape — both can do it
interface Pengguna {
  nama: string;
  email: string;
}

type PenggunaTipe = {
  nama: string;
  email: string;
};

// Both can be used in the same way
const p1: Pengguna = { nama: "Budi", email: "[email protected]" };
const p2: PenggunaTipe = { nama: "Siti", email: "[email protected]" };

A practical guide for choosing between them:

ScenarioRecommendationReason
Object shape / class contractinterfaceCan be extended and implemented, declaration merging
Union typetypeInterfaces can’t be unions
Complex intersectionstypeMore concise with &
TupletypeInterfaces can’t be tuples
Standalone functionstypeMore concise
Public library APIinterfaceDeclaration merging makes augmentation easier
// type MUST be used for unions — interfaces can't
type Status = "aktif" | "nonaktif" | "pending";
type IDFleksibel = string | number;

// type for tuples
type Koordinat = [number, number];
type Rentang = [min: number, max: number];

// interface is better for complex object contracts
interface KonfigurasiServer {
  host: string;
  port: number;
  ssl: boolean;
  timeout?: number;
}

Defining Interfaces #

Interfaces are defined with the interface keyword. All properties declared without ? are required — an object must have all of them with matching types:

interface Produk {
  id: string;
  nama: string;
  harga: number;
  kategori: string;
  stok: number;
}

// An object must satisfy all required properties
const produk: Produk = {
  id: "PRD-001",
  nama: "Kurma Ajwa Premium",
  harga: 125_000,
  kategori: "makanan",
  stok: 50,
};

// ANTI-PATTERN: Excess properties — TypeScript rejects them on direct assignment
// const produkSalah: Produk = {
//   id: "PRD-002",
//   nama: "Madu",
//   harga: 75_000,
//   kategori: "minuman",
//   stok: 20,
//   diskon: 10, // ✗ Error: Object literal may only specify known properties
// };

// But the excess property check doesn't apply when assigning via a variable
const dataDariAPI = {
  id: "PRD-003",
  nama: "Minyak Zaitun",
  harga: 95_000,
  kategori: "dapur",
  stok: 15,
  diskon: 5, // Extra property — no error when passed through a variable
};
const produkDariAPI: Produk = dataDariAPI; // ✓ No error

Structural Typing — Compatibility by Shape #

TypeScript uses structural typing: a value is compatible with an interface if it has at least all the properties the interface requires, regardless of its origin:

interface PunyaNama {
  nama: string;
}

// A function accepting anything with a 'nama' property
function sapaPengguna(entitas: PunyaNama): string {
  return `Halo, ${entitas.nama}!`;
}

// A plain object literal — compatible
sapaPengguna({ nama: "Budi" });

// A class instance — compatible if it has the 'nama' property
class Mahasiswa {
  constructor(public nama: string, public nim: string) {}
}
sapaPengguna(new Mahasiswa("Siti", "20230001")); // ✓ Mahasiswa has 'nama'

// An object with extra properties — still compatible
sapaPengguna({ nama: "Ahmad", jabatan: "Manager" }); // ✓ Has 'nama', the rest is ignored

Optional and Readonly Properties #

Optional Properties with ? #

Optional properties may or may not exist. In code using optional properties, TypeScript forces a check before use:

interface ProfilPengguna {
  nama: string;
  email: string;
  usia?: number;           // Optional
  foto?: string | null;    // Optional, can also be explicitly null
  bio?: string;            // Optional
  sosialMedia?: {
    twitter?: string;
    linkedin?: string;
    github?: string;
  };
}

function buatRingkasanProfil(profil: ProfilPengguna): string {
  // Must narrow before using optional properties
  const infoUsia = profil.usia !== undefined ? `, ${profil.usia} tahun` : "";
  const infoBio  = profil.bio ? ` — "${profil.bio.slice(0, 50)}..."` : "";
  return `${profil.nama} <${profil.email}>${infoUsia}${infoBio}`;
}

// Minimal object — only required properties
const profilMinimal: ProfilPengguna = {
  nama: "Budi Santoso",
  email: "[email protected]",
};

// Full object — all properties filled
const profilLengkap: ProfilPengguna = {
  nama: "Siti Rahma",
  email: "[email protected]",
  usia: 28,
  foto: "https://cdn.example.com/foto/siti.jpg",
  bio: "Software engineer yang gemar open source",
  sosialMedia: {
    github: "github.com/siti",
    linkedin: "linkedin.com/in/siti",
  },
};

readonly Properties #

readonly prevents property modification after initialization. This is enforced at the TypeScript level at compile time:

interface KonfigurasiImmutable {
  readonly databaseUrl: string;
  readonly secretKey: string;
  readonly environment: "development" | "staging" | "production";
  readonly batasCacheDetik: number;
}

const konfigurasi: KonfigurasiImmutable = {
  databaseUrl: "postgresql://localhost:5432/muslimapps",
  secretKey: process.env.SECRET_KEY ?? "dev-secret",
  environment: "production",
  batasCacheDetik: 3600,
};

// konfigurasi.databaseUrl = "url-lain"; // ✗ Error: read-only property
// konfigurasi.secretKey = "bocor";       // ✗ Error: read-only property

// Readonly only applies at the TypeScript level — not runtime enforcement
// For runtime, use Object.freeze() as discussed in the Constants article

Interfaces for Functions and Callables #

Interfaces can define the shape of a function — what parameters it accepts and what type it returns:

// An interface for a regular function
interface FungsiPerbandingan<T> {
  (a: T, b: T): number; // negative, 0, or positive
}

// An interface with optional methods
interface Serializable {
  serialize(): string;
  deserialize?(data: string): void; // Optional method
}

// Using a function interface as a parameter type
function urutkan<T>(arr: T[], bandingkan: FungsiPerbandingan<T>): T[] {
  return [...arr].sort(bandingkan);
}

const angka = [5, 2, 8, 1, 9, 3];
const terurut = urutkan(angka, (a, b) => a - b);
// [1, 2, 3, 5, 8, 9]

const nama = ["Budi", "Ahmad", "Siti", "Dewi"];
const terurutAlfabet = urutkan(nama, (a, b) => a.localeCompare(b, "id"));
// ["Ahmad", "Budi", "Dewi", "Siti"]

Hybrid Types — Function and Object at Once #

Interfaces can define something that acts as a function while also having properties — useful for libraries like jQuery or Express:

interface FungsiDenganKonfigurasi {
  (input: string): string;      // Callable
  versi: string;                // Property
  reset(): void;                // Method
  konfigurasi: {
    caseSensitive: boolean;
    trim: boolean;
  };
}

function buatProsesor(): FungsiDenganKonfigurasi {
  const fn = function (input: string): string {
    const hasil = fn.konfigurasi.trim ? input.trim() : input;
    return fn.konfigurasi.caseSensitive ? hasil : hasil.toLowerCase();
  } as FungsiDenganKonfigurasi;

  fn.versi = "1.0.0";
  fn.konfigurasi = { caseSensitive: false, trim: true };
  fn.reset = () => {
    fn.konfigurasi = { caseSensitive: false, trim: true };
  };

  return fn;
}

const prosesor = buatProsesor();
console.log(prosesor("  Hello World  ")); // "hello world"
console.log(prosesor.versi);              // "1.0.0"

Index Signatures — Dynamic Properties #

Index signatures let interfaces define objects with keys unknown in advance:

// Index signature with string keys
interface KamusString {
  [kunci: string]: string;
}

const terjemahan: KamusString = {
  halo: "hello",
  terima_kasih: "thank you",
  selamat: "congratulations",
};

// Index signature with number keys (for array-like objects)
interface DaftarBerindeks {
  [indeks: number]: string;
  length: number; // Additional property compatible with the index value
}

// Mixing specific properties with an index signature
interface KonfigurasiDinamis {
  host: string;        // Known required property
  port: number;        // Known required property
  [ekstra: string]: string | number; // Free additional properties
  // All specific properties must be compatible with the index signature type
}

const konfig: KonfigurasiDinamis = {
  host: "localhost",
  port: 5432,
  namaDb: "muslimapps",    // Extra property — allowed
  ssl: "true",              // Extra property — must be string or number
};
An overly wide index signature ([kunci: string]: any) removes the benefits of type safety. Better to use Record<string, NilaiSpesifik> or define a more explicit interface. Use index signatures only if the keys are truly dynamic and can’t be known in advance.

Extends — Interface Composition #

Interfaces can inherit properties from one or more other interfaces using extends. This allows building a structured type hierarchy:

// Base interfaces
interface Entitas {
  readonly id: string;
  dibuatPada: Date;
  diperbarui: Date;
}

interface PunyaNama {
  nama: string;
}

interface PunyaAlamat {
  jalan: string;
  kota: string;
  provinsi: string;
  kodePos: string;
}

// Extends from one interface
interface Pengguna extends Entitas {
  email: string;
  passwordHash: string;
  aktif: boolean;
}

// Extends from several interfaces at once
interface Pelanggan extends Entitas, PunyaNama, PunyaAlamat {
  nomorPelanggan: string;
  levelLoyalitas: "bronze" | "silver" | "gold" | "platinum";
  totalBelanja: number;
}

// A child interface can add new properties and override optional ones to required
interface PelangganVIP extends Pelanggan {
  manajerAkun: string;
  limitKredit: number;
}

// Usage — must satisfy all properties from the whole hierarchy
const pelanggan: Pelanggan = {
  id: crypto.randomUUID(),
  dibuatPada: new Date(),
  diperbarui: new Date(),
  nama: "Budi Santoso",
  jalan: "Jl. Sudirman No. 1",
  kota: "Jakarta",
  provinsi: "DKI Jakarta",
  kodePos: "10220",
  nomorPelanggan: "PLG-001",
  levelLoyalitas: "gold",
  totalBelanja: 15_000_000,
};

extends on Interfaces vs Intersection Types #

// Interface extends — has its own name, can be implemented by a class
interface HewanPeliharaan extends PunyaNama {
  jenis: string;
  usia: number;
}

// Intersection type — more flexible, can combine with anything
type HewanPeliharaanType = PunyaNama & {
  jenis: string;
  usia: number;
};

// Both produce the same shape, but:
// - Interfaces are better for classes that implement
// - Intersections are better for ad-hoc composition

Declaration Merging #

A unique feature of interfaces that type aliases don’t have is declaration merging — if you declare an interface with the same name twice, TypeScript merges them automatically:

// First declaration
interface KonfigurasiServer {
  host: string;
  port: number;
}

// Second declaration — merged with the first
interface KonfigurasiServer {
  ssl: boolean;
  timeout: number;
}

// Final result: an interface with all four properties
const server: KonfigurasiServer = {
  host: "localhost",
  port: 3000,
  ssl: false,
  timeout: 30_000,
};

Declaration merging is very useful for augmenting third-party libraries — you can add properties to an interface that already exists in a library without changing its source:

// Example: adding properties to the Express Request interface
// in the file declarations/express.d.ts

declare global {
  namespace Express {
    interface Request {
      pengguna?: {
        id: string;
        peran: string;
      };
      requestId: string;
    }
  }
}

// Now across the whole application, req.pengguna and req.requestId are
// available with full type safety

Interfaces as Contracts for Dependency Injection #

One of the most powerful uses of interfaces is as contracts for dependency injection — allowing implementations to be swapped without changing the code that depends on them:

// Contract — defines "what" can be done
interface RepositoriPengguna {
  ambilById(id: string): Promise<Pengguna | null>;
  simpan(pengguna: Pengguna): Promise<void>;
  hapus(id: string): Promise<boolean>;
  cariByEmail(email: string): Promise<Pengguna | null>;
}

interface LayananEmail {
  kirimSelamatDatang(email: string, nama: string): Promise<void>;
  kirimResetPassword(email: string, token: string): Promise<void>;
}

// The service depends on contracts, not concrete implementations
class LayananPengguna {
  constructor(
    private readonly repo: RepositoriPengguna,
    private readonly email: LayananEmail
  ) {}

  async daftarPengguna(nama: string, emailBaru: string): Promise<Pengguna> {
    const sudahAda = await this.repo.cariByEmail(emailBaru);
    if (sudahAda) throw new Error("Email sudah terdaftar");

    const pengguna: Pengguna = {
      id: crypto.randomUUID(),
      nama,
      email: emailBaru,
      aktif: true,
      dibuatPada: new Date(),
      diperbarui: new Date(),
      passwordHash: "",
    };

    await this.repo.simpan(pengguna);
    await this.email.kirimSelamatDatang(emailBaru, nama);

    return pengguna;
  }
}

// Production implementation
class RepositoriPenggunaMongoDB implements RepositoriPengguna {
  async ambilById(id: string): Promise<Pengguna | null> { /* MongoDB query */ return null; }
  async simpan(pengguna: Pengguna): Promise<void> { /* MongoDB insert */ }
  async hapus(id: string): Promise<boolean> { /* MongoDB delete */ return true; }
  async cariByEmail(email: string): Promise<Pengguna | null> { /* MongoDB query */ return null; }
}

// Testing implementation — easy to swap without changing LayananPengguna
class RepositoriPenggunaMemori implements RepositoriPengguna {
  private store = new Map<string, Pengguna>();

  async ambilById(id: string): Promise<Pengguna | null> {
    return this.store.get(id) ?? null;
  }
  async simpan(pengguna: Pengguna): Promise<void> {
    this.store.set(pengguna.id, pengguna);
  }
  async hapus(id: string): Promise<boolean> {
    return this.store.delete(id);
  }
  async cariByEmail(email: string): Promise<Pengguna | null> {
    for (const p of this.store.values()) {
      if (p.email === email) return p;
    }
    return null;
  }
}

Interface-Based Utility Types #

TypeScript provides built-in utility types that work with interfaces to produce new variants:

interface Artikel {
  id: string;
  judul: string;
  konten: string;
  penulis: string;
  diterbitkan: boolean;
  dibuatPada: Date;
}

// Partial<T> — all properties become optional (for partial updates)
type PembaruanArtikel = Partial<Artikel>;
// { id?: string; judul?: string; konten?: string; ... }

function perbaruiArtikel(id: string, perubahan: PembaruanArtikel): void {
  console.log(`Memperbarui artikel ${id}:`, perubahan);
}
perbaruiArtikel("art-1", { judul: "Judul Baru" }); // Only change the title

// Required<T> — all properties become required (the opposite of Partial)
type ArtikelLengkap = Required<Artikel>;

// Pick<T, K> — take only certain properties
type RingkasanArtikel = Pick<Artikel, "id" | "judul" | "penulis">;
// { id: string; judul: string; penulis: string }

// Omit<T, K> — remove certain properties
type ArtikelBaru = Omit<Artikel, "id" | "dibuatPada">;
// { judul: string; konten: string; penulis: string; diterbitkan: boolean }

// Readonly<T> — all properties become readonly
type ArtikelTerbit = Readonly<Artikel>;

// Record<K, V> — create a dictionary type
type IndeksArtikel = Record<string, Artikel>;

// Real usage example: DTOs for an API
type CreateArtikelDTO = Omit<Artikel, "id" | "dibuatPada">;
type UpdateArtikelDTO = Partial<Omit<Artikel, "id" | "dibuatPada">>;
type ArtikelResponseDTO = Omit<Artikel, "konten">; // No full content for lists

Interface Relationship Map #

flowchart TD
    A[Interface] --> B[Defines Shapes]
    A --> C[Used As]
    A --> D[Special Features]
    A --> E[Interacts With]

    B --> B1[Object shape]
    B --> B2[Function / callable types]
    B --> B3[Index signatures - dynamic keys]
    B --> B4[Hybrid - function + properties]

    C --> C1[Variable and parameter types]
    C --> C2[Function return types]
    C --> C3[Generic constraint extends T]
    C --> C4[Dependency injection contracts]

    D --> D1[Declaration merging]
    D --> D2[extends — multiple inheritance]
    D --> D3[Structural typing — duck typing]

    E --> E1[Class implements]
    E --> E2[Type alias intersection &]
    E --> E3[Utility Types Partial Pick Omit]
    E --> E4[Global library augmentation]

    style D1 fill:#cc5de8,color:#fff
    style D3 fill:#339af0,color:#fff
    style E1 fill:#51cf66,color:#fff
    style E3 fill:#fcc419,color:#000

Summary #

  • Interface vs type alias — use interfaces for object shapes and class contracts because they support extends, implements, and declaration merging; use type for unions, tuples, and complex compositions that don’t need augmentation.
  • Structural typing is the fundamental principle — TypeScript doesn’t care about a type’s name or origin, only its shape; an object is compatible with an interface if it has all the required properties.
  • readonly properties prevent modification after initialization at the compile level; combine with Object.freeze() if you need runtime protection too.
  • Index signatures for objects with dynamic keys; but don’t use [key: string]: any — specify a concrete value type to maintain type safety.
  • Multiple extends lets interfaces inherit from several interfaces at once — this is how TypeScript composes types without multiple inheritance.
  • Declaration merging is a unique interface feature that doesn’t exist in type aliases — very useful for augmenting third-party libraries like adding properties to Express’s Request.
  • Interfaces as dependency injection contracts allow swapping implementations (production vs testing) without changing the dependent code — a fundamental pattern for testable code.
  • Interface-based utility types like Partial<T>, Required<T>, Pick<T, K>, and Omit<T, K> are very useful for creating variants of existing interfaces without duplicating definitions.

← Previous: Classes   Next: Exceptions →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact