Langsung ke konten
KamusNgoding
Terapan Typescript 5 menit baca

Membangun REST API dengan Express dan TypeScript: Tutorial Step-by-Step

#typescript #express #rest api #backend #applied

Membangun REST API dengan Express dan TypeScript: Tutorial Step-by-Step

Pendahuluan

Bayangkan kamu ingin membangun aplikasi backend seperti layanan yang dimiliki Gojek atau Tokopedia — di mana ratusan ribu request masuk setiap detiknya. Kamu butuh API yang tidak hanya cepat, tapi juga mudah di-maintain dan minim bug. Di sinilah kombinasi Express.js dan TypeScript menjadi pilihan utama banyak tim backend profesional.

Express adalah framework Node.js yang minimalis namun powerful untuk membangun server HTTP dan REST API. Sementara TypeScript membawa sistem tipe statis yang membantu kamu menangkap bug lebih awal — sebelum kode sampai ke production. Kombinasi keduanya menghasilkan codebase yang lebih aman, lebih mudah dibaca, dan lebih mudah di-refactor.

Artikel ini akan membimbingmu membangun REST API lengkap dengan operasi CRUD, validasi input, dan penanganan error yang proper. Pastikan kamu sudah familiar dengan konsep dasar TypeScript sebelum melanjutkan — jika belum, ada baiknya cek dulu Tutorial Konfigurasi tsconfig.json untuk Pemula agar setup proyekmu benar dari awal.


Inisialisasi Proyek dan Konfigurasi Dasar

Instalasi Dependencies

Buat folder proyek baru dan inisialisasi package. Jalankan perintah berikut satu per satu di terminal:

mkdir express-typescript-api
cd express-typescript-api
npm init -y

Install Express beserta type definitions dan tools yang diperlukan:

npm install express
npm install -D typescript ts-node nodemon @types/node @types/express

Buat konfigurasi TypeScript dan folder source:

npx tsc --init
mkdir src

Konfigurasi TypeScript

Ganti isi tsconfig.json di root proyek dengan konfigurasi berikut:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "rootDir": "./src",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "noEmitOnError": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Setup Scripts

Update bagian scripts di package.json:

{
  "scripts": {
    "dev": "nodemon --exec ts-node src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Struktur Folder

src/
├── index.ts                      → Entry point server
├── routes/
│   └── product.ts                → Definisi route
├── controllers/
│   └── productController.ts      → Logika handler
├── models/
│   └── product.ts                → Definisi tipe data
└── middleware/
    └── errorHandler.ts           → Middleware penanganan error

Entry Point Server

Buat file src/index.ts:

import express, { NextFunction, Request, Response } from 'express';

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

type Product = {
  id: number;
  name: string;
  price: number;
};

const products: Product[] = [
  { id: 1, name: 'Laptop', price: 8500000 },
  { id: 2, name: 'Keyboard', price: 350000 },
];

app.get('/api/products', (req: Request, res: Response) => {
  res.json({
    message: 'Daftar produk berhasil diambil',
    data: products,
  });
});

app.post('/api/products', (req: Request, res: Response) => {
  const { name, price } = req.body;

  if (!name || !price) {
    return res.status(400).json({ message: 'Nama dan harga produk wajib diisi' });
  }

  const newProduct: Product = {
    id: products.length + 1,
    name,
    price,
  };

  products.push(newProduct);

  res.status(201).json({
    message: 'Produk berhasil ditambahkan',
    data: newProduct,
  });
});

app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  res.status(500).json({
    message: 'Terjadi kesalahan pada server',
    error: err.message,
  });
});

app.listen(PORT, () => {
  console.log(`Server berjalan di http://localhost:${PORT}`);
});

export default app;

/*
Output yang diharapkan:
> Server berjalan di http://localhost:3000
*/

Jalankan server dengan:

npm run dev

Membangun Endpoint API CRUD

Definisi Model dan Tipe Data

Buat file src/models/product.ts:

export interface Product {
  id: number;
  name: string;
  price: number;
  stock: number;
  category: string;
  createdAt: Date;
}

// Tipe untuk membuat produk baru — tanpa id dan createdAt
export type CreateProductDTO = Omit<Product, 'id' | 'createdAt'>;

// Tipe untuk update produk — semua field bersifat opsional
export type UpdateProductDTO = Partial<CreateProductDTO>;

// Contoh penggunaan
const createProduct = (data: CreateProductDTO): Product => {
  return {
    id: 1,
    ...data,
    createdAt: new Date('2026-04-15'),
  };
};

const product = createProduct({
  name: 'Keyboard Mechanical',
  price: 750000,
  stock: 10,
  category: 'Aksesoris',
});

console.log(product.name);    // Keyboard Mechanical
console.log(product.stock);   // 10
console.log(product.price);   // 750000

Dengan TypeScript, kamu bisa mendefinisikan DTO (Data Transfer Object) menggunakan utility types seperti Omit dan Partial — ini mencegah field yang tidak perlu masuk ke request payload.

Controller

Buat file src/controllers/productController.ts dengan data in-memory sebagai simulasi database:

import { Request, Response, NextFunction } from 'express';
import { Product, CreateProductDTO, UpdateProductDTO } from '../models/product';

let products: Product[] = [
  {
    id: 1,
    name: 'Laptop Gaming',
    price: 15000000,
    stock: 10,
    category: 'elektronik',
    createdAt: new Date(),
  },
];

let nextId = 2;

// GET /api/products — mengambil semua produk
export const getAllProducts = (_req: Request, res: Response): void => {
  res.json({
    success: true,
    data: products,
    total: products.length,
  });
};

// GET /api/products/:id — mengambil produk berdasarkan id
export const getProductById = (
  req: Request<{ id: string }>,
  res: Response
): void => {
  const id = parseInt(req.params.id, 10);
  const product = products.find((item) => item.id === id);

  if (!product) {
    res.status(404).json({ success: false, message: 'Produk tidak ditemukan' });
    return;
  }

  res.json({ success: true, data: product });
};

// POST /api/products — membuat produk baru
export const createProduct = (
  req: Request<{}, {}, CreateProductDTO>,
  res: Response
): void => {
  const { name, price, stock, category } = req.body;

  if (!name || price <= 0 || stock < 0 || !category) {
    res.status(400).json({ success: false, message: 'Data produk tidak valid' });
    return;
  }

  const newProduct: Product = {
    id: nextId++,
    name,
    price,
    stock,
    category,
    createdAt: new Date(),
  };

  products.push(newProduct);
  res.status(201).json({ success: true, data: newProduct });
};

// PUT /api/products/:id — memperbarui produk
export const updateProduct = (
  req: Request<{ id: string }, {}, UpdateProductDTO>,
  res: Response
): void => {
  const id = parseInt(req.params.id, 10);
  const index = products.findIndex((item) => item.id === id);

  if (index === -1) {
    res.status(404).json({ success: false, message: 'Produk tidak ditemukan' });
    return;
  }

  products[index] = { ...products[index], ...req.body };
  res.json({ success: true, data: products[index] });
};

// DELETE /api/products/:id — menghapus produk
export const deleteProduct = (
  req: Request<{ id: string }>,
  res: Response
): void => {
  const id = parseInt(req.params.id, 10);
  const index = products.findIndex((item) => item.id === id);

  if (index === -1) {
    res.status(404).json({ success: false, message: 'Produk tidak ditemukan' });
    return;
  }

  products.splice(index, 1);
  res.json({ success: true, message: 'Produk berhasil dihapus' });
};

Routes

Buat file src/routes/product.ts:

import { Router } from 'express';
import {
  getAllProducts,
  getProductById,
  createProduct,
  updateProduct,
  deleteProduct,
} from '../controllers/productController';

const router = Router();

router.get('/', getAllProducts);
router.get('/:id', getProductById);
router.post('/', createProduct);
router.put('/:id', updateProduct);
router.delete('/:id', deleteProduct);

export default router;

Update src/index.ts untuk mendaftarkan route:

import express, { NextFunction, Request, Response } from 'express';
import productRouter from './routes/product';

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());
app.use('/api/products', productRouter);

// Error handler global
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  res.status(500).json({
    success: false,
    message: 'Terjadi kesalahan pada server',
    error: err.message,
  });
});

app.listen(PORT, () => {
  console.log(`Server berjalan di http://localhost:${PORT}`);
});

export default app;

Sekarang kamu punya API dengan endpoint lengkap:

MethodEndpointDeskripsi
GET/api/productsMengambil semua produk
GET/api/products/:idMengambil produk berdasarkan ID
POST/api/productsMembuat produk baru
PUT/api/products/:idMemperbarui produk
DELETE/api/products/:idMenghapus produk

Validasi Data dan Penanganan Error

Custom Error Class

Buat file src/utils/AppError.ts:

class AppError extends Error {
  public statusCode: number;
  public isOperational: boolean;

  constructor(message: string, statusCode: number) {
    super(message);
    this.name = 'AppError';
    this.statusCode = statusCode;
    this.isOperational = true;
    Object.setPrototypeOf(this, AppError.prototype);
  }
}

// Contoh penggunaan
const error = new AppError('Data pengguna tidak ditemukan', 404);

console.log(error.name);           // AppError
console.log(error.message);        // Data pengguna tidak ditemukan
console.log(error.statusCode);     // 404
console.log(error.isOperational);  // true
console.log(error instanceof AppError); // true

export default AppError;

Middleware Error Handler

Buat file src/middleware/errorHandler.ts:

import { Request, Response, NextFunction } from 'express';
import AppError from '../utils/AppError';

const errorHandler = (
  err: Error,
  _req: Request,
  res: Response,
  _next: NextFunction
): void => {
  // Jika error berasal dari AppError, kirim status sesuai error tersebut
  if (err instanceof AppError) {
    res.status(err.statusCode).json({
      success: false,
      message: err.message,
    });
    return;
  }

  // Error tak terduga dicatat agar mudah di-debug
  console.error('Unexpected error:', err);

  res.status(500).json({
    success: false,
    message: 'Terjadi kesalahan pada server',
  });
};

export default errorHandler;

Daftarkan middleware di src/index.ts (ganti error handler inline yang sebelumnya):

import errorHandler from './middleware/errorHandler';

// ... kode sebelumnya ...

app.use(errorHandler); // Harus diletakkan paling akhir

Validasi Input Manual

Perbarui fungsi createProduct di controller agar validasinya lebih lengkap:

export const createProduct = (
  req: Request<{}, {}, CreateProductDTO>,
  res: Response,
  next: NextFunction
): void => {
  try {
    const { name, price, stock, category } = req.body;

    if (typeof name !== 'string' || name.trim() === '') {
      res.status(400).json({ success: false, message: 'Nama produk wajib diisi' });
      return;
    }

    if (typeof price !== 'number' || price <= 0) {
      res.status(400).json({ success: false, message: 'Harga harus berupa angka positif' });
      return;
    }

    if (typeof stock !== 'number' || stock < 0) {
      res.status(400).json({ success: false, message: 'Stok tidak boleh negatif' });
      return;
    }

    const newProduct: Product = {
      id: nextId++,
      name: name.trim(),
      price,
      stock,
      category: category?.trim() || 'umum',
      createdAt: new Date(),
    };

    products.push(newProduct);

    res.status(201).json({
      success: true,
      data: newProduct,
    });
  } catch (error) {
    next(error);
  }
};

Contoh Kasus Nyata

Menambah Fitur Filter dan Pagination

API yang baik — seperti yang digunakan pada platform e-commerce — selalu mendukung filter dan pagination. Berikut cara mengimplementasikannya dengan tipe yang aman:

import express, { Request, Response } from 'express';

const app = express();
const PORT = 3000;

interface Product {
  id: number;
  name: string;
  category: string;
  price: number;
}

interface ProductQuery {
  category?: string;
  minPrice?: string;
  maxPrice?: string;
  page?: string;
  limit?: string;
}

const products: Product[] = [
  { id: 1, name: 'Keyboard Mechanical', category: 'electronics', price: 750000 },
  { id: 2, name: 'Mouse Wireless', category: 'electronics', price: 250000 },
  { id: 3, name: 'Notebook A5', category: 'stationery', price: 35000 },
  { id: 4, name: 'Pulpen Gel', category: 'stationery', price: 12000 },
];

app.get(
  '/products',
  (req: Request<{}, {}, {}, ProductQuery>, res: Response): void => {
    let result = [...products];

    const { category, minPrice, maxPrice, page = '1', limit = '10' } = req.query;

    if (category) {
      result = result.filter((p) => p.category === category);
    }

    const min = minPrice ? Number(minPrice) : undefined;
    const max = maxPrice ? Number(maxPrice) : undefined;

    if (min !== undefined) {
      result = result.filter((p) => p.price >= min);
    }

    if (max !== undefined) {
      result = result.filter((p) => p.price <= max);
    }

    const pageNum = Math.max(Number(page), 1);
    const limitNum = Math.max(Number(limit), 1);
    const startIndex = (pageNum - 1) * limitNum;
    const paginatedProducts = result.slice(startIndex, startIndex + limitNum);

    res.json({
      success: true,
      data: paginatedProducts,
      pagination: {
        total: result.length,
        page: pageNum,
        limit: limitNum,
        totalPages: Math.ceil(result.length / limitNum),
      },
    });
  }
);

app.listen(PORT, () => {
  console.log(`Server berjalan di http://localhost:${PORT}`);
});

/*
Output yang diharapkan:
> Buka: http://localhost:3000/products?category=electronics&minPrice=300000
> Mengembalikan produk electronics dengan harga minimal 300000
*/

Kamu bisa hit endpoint dengan query string seperti:

GET /api/products?category=elektronik&minPrice=5000000&page=1&limit=5

Pola ini sangat berguna saat membangun dashboard admin atau fitur search produk — mirip seperti yang ada pada marketplace lokal besar.

Jika proyekmu berkembang dan butuh deploy otomatis, kamu bisa eksplorasi Tutorial GitHub Actions untuk Pemula: Menjalankan Workflow Pertama Anda untuk mengotomasi proses build dan deployment API-mu.


Pertanyaan yang Sering Diajukan

Apa perbedaan Request<P, ResBody, ReqBody, Query> di Express TypeScript?

Generic parameter pada Request memungkinkan kamu mendefinisikan tipe untuk empat hal: P adalah tipe params URL (misalnya { id: string }), ResBody adalah tipe response body, ReqBody adalah tipe request body dari POST/PUT, dan Query adalah tipe query string. Dengan ini, TypeScript bisa memberikan autocomplete dan type-checking yang akurat pada setiap layer handler.

Bagaimana cara menambahkan autentikasi JWT ke API Express TypeScript?

Kamu perlu membuat middleware yang memverifikasi token dari header Authorization. Install library jsonwebtoken dan @types/jsonwebtoken, lalu buat fungsi middleware yang memanggil next() jika token valid atau mengembalikan status 401 jika tidak. Middleware ini kemudian dipasang di route yang membutuhkan proteksi menggunakan router.use(authMiddleware) sebelum handler route-nya.

Mengapa menggunakan Omit dan Partial untuk DTO daripada interface terpisah?

Utility types TypeScript seperti Omit<Product, 'id' | 'createdAt'> dan Partial<CreateProductDTO> memungkinkanmu mendefinisikan tipe turunan dari satu sumber kebenaran (interface Product). Ini mencegah duplikasi definisi dan memastikan jika kamu mengubah field di Product, semua DTO ikut ter-update secara otomatis — mengurangi risiko bug yang muncul dari inkonsistensi tipe.

Apakah Express + TypeScript cocok untuk production?

Sangat cocok. Banyak perusahaan teknologi menggunakan stack ini di production karena Node.js mampu menangani banyak concurrent connection dengan event loop non-blocking, sementara TypeScript memastikan kode lebih maintainable di tim yang besar. Untuk skala besar, kamu bisa menambahkan layer seperti database PostgreSQL/MySQL, ORM (Prisma/TypeORM), dan caching Redis.

Bagaimana cara menangani error async di Express TypeScript?

Express tidak menangkap error async secara otomatis. Kamu perlu membungkus handler async dalam try-catch dan memanggil next(error), atau menggunakan wrapper utility seperti ini:

import { Request, Response, NextFunction, RequestHandler } from 'express';

type AsyncHandler = (
  req: Request,
  res: Response,
  next: NextFunction
) => Promise<void>;

// Membungkus handler async agar error otomatis diteruskan ke middleware error
const asyncHandler = (fn: AsyncHandler): RequestHandler =>
  (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };

export default asyncHandler;

Kemudian gunakan router.get('/', asyncHandler(getProducts)) agar semua error async otomatis diteruskan ke error middleware.


Kesimpulan

Kamu telah berhasil membangun REST API lengkap dengan Express dan TypeScript — mulai dari konfigurasi proyek, membuat endpoint CRUD, validasi input, penanganan error yang terstruktur, hingga fitur filter dan pagination yang siap pakai. Dengan TypeScript, setiap layer API-mu menjadi lebih aman karena compiler akan menangkap kesalahan tipe sebelum kode dijalankan.

Langkah selanjutnya yang bisa kamu eksplorasi adalah mengintegrasikan database nyata menggunakan Prisma atau TypeORM, menambahkan autentikasi JWT, atau menyiapkan pipeline deployment otomatis. Selamat belajar dan terus berlatih — setiap baris kode yang kamu tulis hari ini adalah investasi untuk karir backend yang lebih solid! Jika ada pertanyaan atau ingin memperdalam topik lainnya, jangan ragu untuk eksplorasi artikel-artikel lain di KamusNgoding.

Artikel Terkait