Functions #

Functions are the fundamental unit of reusable code — and in TypeScript, functions get special treatment from the type system. Every aspect of a function can — and should — be annotated: parameters, return values, even functions received as arguments. TypeScript also extends JavaScript function capabilities with real overloading, flexible generics, and type-safe async-await integration. Understanding how to write correct functions in TypeScript isn’t just about adding type annotations — it’s about designing a clear contract between the caller and the implementation, so the compiler can help detect mismatches before the code runs.

The Anatomy of a TypeScript Function #

Before diving into the variations, it’s important to understand all the parts that can be annotated in a function:

//     ┌── function name
//     │       ┌── typed parameter
//     │       │                     ┌── return type
//     ▼       ▼                     ▼
function hitung(a: number, b: number): number {
  return a + b; // The return value must match the return type
}

// TypeScript infers the return type if not written explicitly
// — but for public functions, always write it explicitly so the API is clear

Why is an explicit return type important for public functions?

// ANTI-PATTERN: Without an explicit return type — hidden bugs
function cariPengguna(id: number) {
  if (id > 0) {
    return { nama: "Budi", email: "[email protected]" };
  }
  // Implicitly returns undefined — TypeScript infers the return type as
  // { nama: string; email: string } | undefined
  // But users of this function don't know there's a possible undefined!
}

// CORRECT: Explicit return type — a clear function contract
function cariPengguna2(id: number): { nama: string; email: string } | null {
  if (id > 0) {
    return { nama: "Budi", email: "[email protected]" };
  }
  return null; // ✓ The compiler ensures all return paths return the correct type
}

Function Declarations vs Function Expressions vs Arrow Functions #

There are three ways to define functions in TypeScript, each with different characteristics:

// 1. Function declaration — hoisted to the top of its scope
function tambah(a: number, b: number): number {
  return a + b;
}

// 2. Function expression — not hoisted, stored in a variable
const kurang = function (a: number, b: number): number {
  return a - b;
};

// 3. Arrow function — concise syntax, lexical this (no own this)
const kali = (a: number, b: number): number => a * b;

// Multi-line arrow function
const bagi = (a: number, b: number): number => {
  if (b === 0) throw new Error("Tidak bisa dibagi nol");
  return a / b;
};

The this Difference — Critical for Class Methods #

The most important difference between arrow functions and regular functions is how they handle this. Arrow functions inherit this from the scope where they’re defined (lexical this), while regular functions have their own this that depends on how they’re called:

class Timer {
  private hitungan = 0;

  // ANTI-PATTERN: Using a regular function as a callback
  // 'this' inside the callback doesn't refer to the Timer instance
  mulaiSalah(): void {
    setInterval(function () {
      this.hitungan++; // ✗ 'this' is undefined in strict mode
      console.log(this.hitungan);
    }, 1000);
  }

  // CORRECT: Arrow functions inherit 'this' from Timer
  mulaiBenar(): void {
    setInterval(() => {
      this.hitungan++; // ✓ 'this' is the Timer instance
      console.log(this.hitungan);
    }, 1000);
  }
}

Function Types as Variables #

You can define a function’s type explicitly using the (params) => ReturnType syntax:

// Defining function types
type OperasiMatematika = (a: number, b: number) => number;
type Validator<T> = (nilai: T) => boolean;
type Transformer<TInput, TOutput> = (input: TInput) => TOutput;

// A variable with a function type
const operasi: OperasiMatematika = (a, b) => a + b;
// TypeScript knows a and b are numbers — no need to re-annotate

const validasiEmail: Validator<string> = (email) =>
  /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);

const keString: Transformer<number, string> = (n) => n.toString();

Parameters: Optional, Default, and Rest #

Optional Parameters with ? #

Optional parameters are marked with ? and always have the type T | undefined inside the function:

function buatProfil(
  nama: string,
  usia?: number,        // Type inside the function: number | undefined
  kota?: string         // Type inside the function: string | undefined
): string {
  const bagianUsia = usia !== undefined ? `, ${usia} tahun` : "";
  const bagianKota = kota ? ` dari ${kota}` : "";
  return `${nama}${bagianUsia}${bagianKota}`;
}

console.log(buatProfil("Budi"));               // "Budi"
console.log(buatProfil("Budi", 25));           // "Budi, 25 tahun"
console.log(buatProfil("Budi", 25, "Jakarta")); // "Budi, 25 tahun dari Jakarta"

// RULE: Optional parameters must always come at the end of the parameter list
// function salah(nama?: string, usia: number): string {} // ✗ Error

Default Parameters #

Parameters with default values provide a fallback value when the argument isn’t given or is given as undefined:

function kirimEmail(
  tujuan: string,
  subjek: string,
  prioritas: "rendah" | "normal" | "tinggi" = "normal",
  maxPercobaan: number = 3
): void {
  console.log(`Kirim ke ${tujuan}: "${subjek}" [${prioritas}] (max ${maxPercobaan}x)`);
}

kirimEmail("[email protected]", "Selamat Datang");
// "Kirim ke [email protected]: "Selamat Datang" [normal] (max 3x)"

kirimEmail("[email protected]", "Peringatan Kritis", "tinggi");
// "Kirim ke [email protected]: "Peringatan Kritis" [tinggi] (max 3x)"

// Explicitly passing undefined will use the default value
kirimEmail("[email protected]", "Info", undefined, 5);
// "Kirim ke [email protected]: "Info" [normal] (max 5x)"

Rest Parameters #

Rest parameters collect all remaining arguments into an array. They must always be the last parameter:

// Basic rest parameter
function jumlahkan(...angka: number[]): number {
  return angka.reduce((total, n) => total + n, 0);
}

console.log(jumlahkan(1, 2, 3));         // 6
console.log(jumlahkan(10, 20, 30, 40));  // 100

// Combining regular and rest parameters
function log(level: "info" | "warn" | "error", ...pesan: string[]): void {
  const timestamp = new Date().toISOString();
  const teksLengkap = pesan.join(" ");
  console.log(`[${timestamp}] [${level.toUpperCase()}] ${teksLengkap}`);
}

log("info", "Server", "dimulai", "di port 3000");
log("error", "Koneksi database gagal:", "timeout setelah 30 detik");

Overloading — One Function, Many Signatures #

Overloading in TypeScript allows one function to have several different type signatures. This differs from languages like Java — in TypeScript, overloading only exists at the type level, not at the implementation level:

// Overload signatures (no implementation)
function format(nilai: number): string;
function format(nilai: string): string;
function format(nilai: Date): string;

// A single implementation handling all signatures
function format(nilai: number | string | Date): string {
  if (typeof nilai === "number") {
    return nilai.toLocaleString("id-ID");
  }
  if (typeof nilai === "string") {
    return nilai.trim();
  }
  // Remaining: nilai is a Date
  return nilai.toLocaleDateString("id-ID", {
    weekday: "long",
    year: "numeric",
    month: "long",
    day: "numeric",
  });
}

// TypeScript shows the relevant signature based on the argument
console.log(format(1_500_000));       // "1.500.000"
console.log(format("  Halo Dunia  ")); // "Halo Dunia"
console.log(format(new Date()));       // "Kamis, 7 Mei 2026"

Overloading with Different Parameter Counts #

// Overloads for creating a range of numbers
function rentang(selesai: number): number[];
function rentang(mulai: number, selesai: number): number[];
function rentang(mulai: number, selesai: number, langkah: number): number[];

function rentang(mulaiAtauSelesai: number, selesai?: number, langkah = 1): number[] {
  const [mulai, akhir] =
    selesai === undefined ? [0, mulaiAtauSelesai] : [mulaiAtauSelesai, selesai];

  const hasil: number[] = [];
  for (let i = mulai; i < akhir; i += langkah) {
    hasil.push(i);
  }
  return hasil;
}

console.log(rentang(5));        // [0, 1, 2, 3, 4]
console.log(rentang(2, 7));     // [2, 3, 4, 5, 6]
console.log(rentang(0, 10, 2)); // [0, 2, 4, 6, 8]

Higher-Order Functions and Closures #

A higher-order function (HOF) is a function that takes another function as an argument or returns a function. This is a fundamental pattern in functional programming and very common in TypeScript:

// A function that takes a function as a parameter
function transformArray<T, U>(
  arr: T[],
  transformFn: (item: T, indeks: number) => U
): U[] {
  return arr.map(transformFn);
}

const angka = [1, 2, 3, 4, 5];
const dikuadratkan = transformArray(angka, (n) => n ** 2);
// Type: number[] → [1, 4, 9, 16, 25]

const daftarNama = ["budi", "siti", "ahmad"];
const dikapitalisasi = transformArray(daftarNama, (nama, i) =>
  `${i + 1}. ${nama.charAt(0).toUpperCase()}${nama.slice(1)}`
);
// Type: string[] → ["1. Budi", "2. Siti", "3. Ahmad"]

// A function that returns a function (closure)
function buatPenghitung(mulaiDari: number = 0) {
  let hitungan = mulaiDari;

  return {
    tambah: () => ++hitungan,
    kurang: () => --hitungan,
    reset: () => { hitungan = mulaiDari; },
    nilai: () => hitungan,
  };
}

const counter = buatPenghitung(10);
counter.tambah(); // 11
counter.tambah(); // 12
counter.kurang(); // 11
console.log(counter.nilai()); // 11

Currying — Functions That Return Functions #

Currying transforms a function with many parameters into a chain of one-parameter functions:

// Regular function
function tambah(a: number, b: number): number {
  return a + b;
}

// Curried version — can be partially applied
function tambahCurried(a: number) {
  return (b: number): number => a + b;
}

const tambah5 = tambahCurried(5); // A function that always adds 5
console.log(tambah5(3));  // 8
console.log(tambah5(10)); // 15

// Currying is very useful for initial configuration
function buatValidator(panjangMin: number, panjangMaks: number) {
  return (teks: string): boolean =>
    teks.length >= panjangMin && teks.length <= panjangMaks;
}

const validasiNama     = buatValidator(2, 50);
const validasiPassword = buatValidator(8, 128);
const validasiBio      = buatValidator(0, 160);

console.log(validasiNama("Budi"));     // true
console.log(validasiPassword("abc"));  // false — too short

Generic Functions — Functions for All Types #

Generics allow functions to work with many types while still maintaining type safety. TypeScript infers generic types from the given arguments:

// Basic generic — T is a type "placeholder"
function identitas<T>(nilai: T): T {
  return nilai;
}

const angka = identitas(42);        // T inferred: number
const teks  = identitas("halo");    // T inferred: string
const obj   = identitas({ a: 1 }); // T inferred: { a: number }

// Generic with a constraint — T must have certain properties
function ambilPertama<T extends { panjang: number }>(koleksi: T): T {
  if (koleksi.panjang === 0) throw new Error("Koleksi kosong");
  return koleksi;
}

// Generic with multiple type parameters
function pasangkan<TKunci, TNilai>(kunci: TKunci, nilai: TNilai): [TKunci, TNilai] {
  return [kunci, nilai];
}

const pasangan = pasangkan("nama", "Budi"); // Type: [string, string]
const campuran = pasangkan(1, true);         // Type: [number, boolean]

// Generic with a condition (conditional type) — advanced
function pertama<T>(arr: T[]): T | undefined {
  return arr[0];
}

function pertamaPasti<T>(arr: [T, ...T[]]): T {
  // The parameter is typed as a non-empty tuple — guarantees the array isn't empty
  return arr[0];
}

const nilaiAman   = pertama([1, 2, 3]);   // Type: number | undefined
const nilaiPasti  = pertamaPasti([1, 2, 3]); // Type: number (no undefined)
// pertamaPasti([]);                         // ✗ Compilation error — the array must have elements

Async-Await Functions and Error Handling #

Async functions return Promise<T> — TypeScript ensures the awaited type matches the declared type:

interface DataPengguna {
  id: number;
  nama: string;
  email: string;
}

// Basic async function — return type Promise<DataPengguna>
async function ambilPengguna(id: number): Promise<DataPengguna> {
  const response = await fetch(`https://api.example.com/pengguna/${id}`);

  if (!response.ok) {
    throw new Error(`Gagal mengambil data: HTTP ${response.status}`);
  }

  const data = await response.json();
  return data as DataPengguna; // Type assertion after validating response.ok
}

// Calling with try-catch
async function tampilkanPengguna(id: number): Promise<void> {
  try {
    const pengguna = await ambilPengguna(id);
    console.log(`Nama: ${pengguna.nama}`);
    console.log(`Email: ${pengguna.email}`);
  } catch (error) {
    // In the catch block, error is of type 'unknown' — must be narrowed first
    if (error instanceof Error) {
      console.error(`Error: ${error.message}`);
    } else {
      console.error("Terjadi error yang tidak diketahui");
    }
  }
}

The Result Pattern for Safer Error Handling #

try-catch forces error handling to be separate from the normal flow. The Result pattern returns success or error as a regular value:

type Result<T, E = Error> =
  | { sukses: true; data: T }
  | { sukses: false; error: E };

async function ambilPenggunaSafe(id: number): Promise<Result<DataPengguna>> {
  try {
    const response = await fetch(`https://api.example.com/pengguna/${id}`);
    if (!response.ok) {
      return {
        sukses: false,
        error: new Error(`HTTP ${response.status}: ${response.statusText}`),
      };
    }
    const data = await response.json();
    return { sukses: true, data: data as DataPengguna };
  } catch (error) {
    return {
      sukses: false,
      error: error instanceof Error ? error : new Error(String(error)),
    };
  }
}
// Usage — no try-catch, errors are handled inline
async function prosesPengguna(id: number): Promise<void> {
  const hasil = await ambilPenggunaSafe(id);

  if (!hasil.sukses) {
    console.error(`Gagal: ${hasil.error.message}`);
    return;
  }

  // TypeScript knows hasil.data is of type DataPengguna here
  console.log(`Selamat datang, ${hasil.data.nama}!`);
}

Running Promises in Parallel #

async function ambilSemuaData(): Promise<void> {
  // ANTI-PATTERN: Serial awaits — slow because it waits one by one
  const pengguna1 = await ambilPengguna(1); // Wait...
  const pengguna2 = await ambilPengguna(2); // Only starts after 1 finishes
  const pengguna3 = await ambilPengguna(3); // Only starts after 2 finishes

  // CORRECT: Promise.all — parallel, faster
  const [p1, p2, p3] = await Promise.all([
    ambilPengguna(1), // All start at the same time
    ambilPengguna(2),
    ambilPengguna(3),
  ]);
  // Type: [DataPengguna, DataPengguna, DataPengguna]

  // Promise.allSettled — continues even if some fail
  const hasil = await Promise.allSettled([
    ambilPengguna(1),
    ambilPengguna(999), // Invalid ID — will be rejected
    ambilPengguna(3),
  ]);

  for (const r of hasil) {
    if (r.status === "fulfilled") {
      console.log(`Berhasil: ${r.value.nama}`);
    } else {
      console.error(`Gagal: ${r.reason}`);
    }
  }
}

Concept Map: TypeScript Function Types #

flowchart TD
    A[TypeScript Functions] --> B[By Definition]
    A --> C[By Parameter]
    A --> D[By Pattern]

    B --> B1[Function declaration]
    B --> B2[Function expression]
    B --> B3[Arrow function]

    C --> C1[Required parameters]
    C --> C2[Optional parameters ?]
    C --> C3[Default parameters]
    C --> C4[Rest parameters ...]
    C --> C5[Destructured parameters]

    D --> D1[Higher-order function]
    D --> D2[Generic function T]
    D --> D3[Overloaded function]
    D --> D4[Async function]
    D --> D5[Generator function*]
    D --> D6[Curried function]

    B3 -- Lexical this --> E[Great for callbacks\\nand arrow methods]
    B1 -- Hoisted --> F[Can be called\\nbefore declaration]
    D4 -- Returns --> G[Promise di T ]
    D5 -- Yields --> H[Generator di T ]

Summary #

  • Always write explicit return types for public functions — this defines a clear contract and forces all return paths to return the correct type; let TypeScript infer return types only for private functions or short arrow functions.
  • Arrow functions for callbacks — arrow functions inherit this from the outer scope (lexical this), making them safe to use as callbacks inside class methods; regular functions have their own this that can surprise you.
  • Optional ? vs default = parameters — optional parameters must be narrowed (!== undefined) before use; default parameters are more practical because they immediately have a fallback value without manual checks.
  • Rest parameters for variadic arguments — collect unlimited arguments with ...nama: T[]; always make them the last parameter because no other parameter may follow.
  • Overloading for expressive APIs — write several overload signatures without implementation, then one implementation handling all signatures; this gives function users accurate IntelliSense.
  • Generics <T> for flexibility without losing types — use a type parameter when the implementation is the same for many types; add extends as a constraint if the function needs certain properties or methods.
  • The Result pattern is safer than try-catch for business flows — return { sukses: true; data: T } | { sukses: false; error: E } so callers are forced to handle errors without being able to forget them.
  • Promise.all for parallel operations — don’t await promises one by one if there’s no dependency between operations; Promise.all is far more efficient, and Promise.allSettled for cases where partial failures can still be processed.
  • Errors in catch blocks are typed unknown — with strict: true, TypeScript doesn’t allow direct access to error in catch; always narrow with instanceof Error before accessing error.message.

← Previous: Loops   Next: Classes →

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