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:
| Method | Endpoint | Deskripsi |
|---|---|---|
| GET | /api/products | Mengambil semua produk |
| GET | /api/products/:id | Mengambil produk berdasarkan ID |
| POST | /api/products | Membuat produk baru |
| PUT | /api/products/:id | Memperbarui produk |
| DELETE | /api/products/:id | Menghapus 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.