karawaci.kode

← Semua snippet

TypeScript Menengah Utility

Fastify plugin pattern dengan TypeScript generics

Bikin Fastify plugin yang fully type-safe — decorator, hooks, request properties terdeteksi TS dari plugin lain. Pattern decorate + augment.

Dipublikasikan 26 Juni 2026

Fastify plugin ekosistem-nya rapi tapi pattern typing-nya sering bingungin. Decorator yang di-attach di plugin A harus terdeteksi di plugin B. Snippet ini contoh plugin db dan auth dengan TypeScript module augmentation supaya request.user dan app.db auto-complete di route handler.

Kode

// src/plugins/db.ts
import fp from "fastify-plugin";
import { Pool } from "pg";
import type { FastifyPluginAsync } from "fastify";

declare module "fastify" {
  interface FastifyInstance {
    db: Pool;
  }
}

interface DbPluginOptions {
  connectionString: string;
  maxConnections?: number;
}

const dbPlugin: FastifyPluginAsync<DbPluginOptions> = async (app, opts) => {
  const pool = new Pool({
    connectionString: opts.connectionString,
    max: opts.maxConnections ?? 10,
  });

  // Smoke test koneksi saat boot
  await pool.query("SELECT 1");
  app.log.info("DB pool ready");

  app.decorate("db", pool);

  app.addHook("onClose", async () => {
    app.log.info("Closing DB pool");
    await pool.end();
  });
};

export default fp(dbPlugin, {
  name: "db",
  fastify: "5.x",
});
// src/plugins/auth.ts
import fp from "fastify-plugin";
import jwt from "jsonwebtoken";
import type { FastifyPluginAsync, FastifyRequest } from "fastify";

interface AuthUser {
  id: number;
  email: string;
  role: "admin" | "user" | "staff";
}

declare module "fastify" {
  interface FastifyRequest {
    user?: AuthUser;
  }
  interface FastifyInstance {
    authenticate: (req: FastifyRequest) => Promise<AuthUser>;
    requireRole: (role: AuthUser["role"]) => (req: FastifyRequest) => Promise<void>;
  }
}

interface AuthPluginOptions {
  secret: string;
}

const authPlugin: FastifyPluginAsync<AuthPluginOptions> = async (app, opts) => {
  // Helper untuk decode + verifikasi
  async function authenticate(req: FastifyRequest): Promise<AuthUser> {
    const header = req.headers.authorization;
    if (!header?.startsWith("Bearer ")) {
      throw app.httpErrors.unauthorized("Token tidak ada");
    }

    const token = header.slice(7);
    try {
      const payload = jwt.verify(token, opts.secret) as AuthUser;
      // Optional: re-fetch user dari DB untuk role check terbaru
      const result = await app.db.query<AuthUser>(
        "SELECT id, email, role FROM users WHERE id = $1",
        [payload.id]
      );

      if (result.rows.length === 0) {
        throw app.httpErrors.unauthorized("User tidak ditemukan");
      }

      const user = result.rows[0];
      req.user = user;
      return user;
    } catch (err) {
      if (err instanceof jwt.JsonWebTokenError) {
        throw app.httpErrors.unauthorized("Token tidak valid");
      }
      throw err;
    }
  }

  function requireRole(role: AuthUser["role"]) {
    return async (req: FastifyRequest) => {
      const user = await authenticate(req);
      if (user.role !== role && user.role !== "admin") {
        throw app.httpErrors.forbidden(`Butuh role ${role}`);
      }
    };
  }

  app.decorate("authenticate", authenticate);
  app.decorate("requireRole", requireRole);
};

// dependencies: pastikan db plugin load duluan
export default fp(authPlugin, {
  name: "auth",
  dependencies: ["db"],
});
// src/server.ts
import Fastify from "fastify";
import sensible from "@fastify/sensible";
import dbPlugin from "./plugins/db";
import authPlugin from "./plugins/auth";

const app = Fastify({ logger: true });

await app.register(sensible); // app.httpErrors.*
await app.register(dbPlugin, {
  connectionString: process.env.DATABASE_URL!,
});
await app.register(authPlugin, {
  secret: process.env.JWT_SECRET!,
});

// Route dengan type-safe access ke db + authenticate
app.get(
  "/api/me",
  { preHandler: app.authenticate },
  async (req) => {
    // req.user fully typed sebagai AuthUser (bukan undefined)
    return { id: req.user!.id, email: req.user!.email };
  }
);

app.get(
  "/api/admin/produk",
  { preHandler: app.requireRole("admin") },
  async (_req) => {
    const result = await app.db.query<{ id: number; nama: string }>(
      "SELECT id, nama FROM produk ORDER BY id DESC LIMIT 20"
    );
    return result.rows;
  }
);

await app.listen({ port: 3000, host: "0.0.0.0" });

Pemakaian

// Test plugin via inject (tanpa start server)
import { test } from "node:test";
import { strict as assert } from "node:assert";

test("authenticated route butuh token", async () => {
  const response = await app.inject({
    method: "GET",
    url: "/api/me",
  });

  assert.equal(response.statusCode, 401);
});

test("authenticated route dengan token valid", async () => {
  const token = jwt.sign({ id: 1, email: "asti@id", role: "user" }, secret);

  const response = await app.inject({
    method: "GET",
    url: "/api/me",
    headers: { authorization: `Bearer ${token}` },
  });

  assert.equal(response.statusCode, 200);
});
// Plugin tambahan reuse plugin yang sudah ada
const orderPlugin: FastifyPluginAsync = async (app) => {
  app.post(
    "/api/order",
    { preHandler: app.authenticate },
    async (req, reply) => {
      const { produk_id, qty } = req.body as { produk_id: number; qty: number };

      const result = await app.db.query<{ id: number }>(
        "INSERT INTO orders (user_id, produk_id, qty) VALUES ($1,$2,$3) RETURNING id",
        [req.user!.id, produk_id, qty]
      );

      return reply.code(201).send({ order_id: result.rows[0].id });
    }
  );
};

Kapan dipakai

  • Backend API dengan banyak cross-cutting concern (auth, DB, logging, metrics).
  • Microservices yang share plugin via internal npm package.
  • App dengan multiple environment (test/dev/prod) yang butuh DI clean.
  • Project yang growth-stage — modular plugin tanggal jadi clean architecture.

Catatan

  • fp() wrapper wajib supaya plugin tidak encapsulated. Tanpa fp, decorator hanya accessible di plugin tree dalam saja.
  • declare module “fastify” — module augmentation harus di file yang di-import oleh tsc. Kalau pakai esbuild dengan tree-shake aggresif, pastikan plugin file tidak di-shake.
  • dependencies: [“db”] — Fastify enforce order plugin. Kalau auth load duluan tanpa dependency, app.db undefined.
  • Decorator name conflictapp.decorate("user", ...) clash dengan request.user. Pakai prefix konsisten kalau perlu.
  • Async plugin — semua plugin pakai async + await app.register. Tanpa await, race condition saat startup.

Jangan abuse decorator untuk passing data per-request. Pakai request.decorateProperty (singular instance) atau context object. Decorate instance untuk shared resource (DB pool, logger).

# tags

fastifyplugintypescriptdecoratornode

Ditulis oleh Asti Larasati · 26 Juni 2026