Introduction to Hapi with TypeScript
Hapi (short for Http API) is a powerful Node.js framework designed for building scalable web applications, APIs, and services. When combined with TypeScript, Hapi becomes an even more formidable toolβgiving you end-to-end type safety, superior developer ergonomics, and self-documenting codebases that catch errors at compile time rather than at runtime.
This tutorial walks you through building a fully typed Hapi application from scratch. You will learn how to set up a project, define typed routes, leverage request and response validation, work with plugins, and adopt best practices that make your applications robust and maintainable.
Why Strong Typing Matters in Hapi
π Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →JavaScript's dynamic nature allows rapid prototyping but can lead to subtle bugs: misspelled route parameters, incorrect payload shapes, or mismatched plugin configurations often surface only when requests hit your server. TypeScript changes this equation entirely.
- Compile-time validation β Route handlers, request payloads, and response objects are checked before your server even starts
- Intelligent autocompletion β Your IDE understands the shape of request objects, lifecycle hooks, and plugin options
- Refactoring confidence β Rename a route parameter or change a payload interface, and TypeScript flags every affected consumer
- Self-documenting code β Interfaces and types serve as living documentation for your API contract
The official @hapi/hapi package includes TypeScript type definitions out of the box, and the companion packages like @hapi/joi and @hapi/boom are also fully typed. This makes adopting TypeScript with Hapi seamless.
Project Setup
Begin by creating a new directory and initializing a Node.js project with TypeScript support.
mkdir hapi-ts-tutorial
cd hapi-ts-tutorial
npm init -y
Install the core dependencies:
npm install @hapi/hapi @hapi/joi @hapi/boom
npm install --save-dev typescript @types/node ts-node nodemon
Create a tsconfig.json file at the project root. This configuration targets modern Node.js versions and enables strict type checking.
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Add the following scripts to your package.json for development convenience:
"scripts": {
"build": "tsc",
"dev": "nodemon --exec ts-node src/server.ts",
"start": "node dist/server.js"
}
Creating a Strongly Typed Hapi Server
Create the src directory and add the main server file. Here we define the server configuration with explicit types and register a basic route.
// src/server.ts
import Hapi from '@hapi/hapi';
interface ServerConfig {
host: string;
port: number;
}
const serverConfig: ServerConfig = {
host: process.env.HOST || '0.0.0.0',
port: Number(process.env.PORT) || 3000,
};
async function initServer(): Promise {
const server: Hapi.Server = Hapi.server({
host: serverConfig.host,
port: serverConfig.port,
router: {
isCaseSensitive: false,
stripTrailingSlash: true,
},
});
// Register a simple health-check route
server.route({
method: 'GET',
path: '/health',
handler: (_request: Hapi.Request, h: Hapi.ResponseToolkit) => {
return h.response({
status: 'ok',
timestamp: new Date().toISOString(),
}).code(200);
},
});
return server;
}
async function start(): Promise {
try {
const server = await initServer();
await server.start();
console.log(`Server running on ${server.info.uri}`);
} catch (err) {
console.error('Failed to start server:', err);
process.exit(1);
}
}
start();
Notice how Hapi.Server, Hapi.Request, and Hapi.ResponseToolkit are explicitly typed. TypeScript now ensures that every method call and property access on these objects is valid.
Typed Route Parameters and Payloads
Raw requests carry unknown data. Hapi's built-in validation powered by Joi lets you define schemas that parse and validate incoming parameters, payloads, and headers. With TypeScript, you can go further by defining interfaces for these validated shapes and using them throughout your handlers.
Defining Validation Schemas with Joi
// src/schemas/userSchemas.ts
import Joi from '@hapi/joi';
export const createUserPayloadSchema = Joi.object({
username: Joi.string().alphanum().min(3).max(30).required(),
email: Joi.string().email().required(),
password: Joi.string().min(8).max(128).required(),
role: Joi.string().valid('admin', 'editor', 'viewer').default('viewer'),
});
export const userIdParamsSchema = Joi.object({
userId: Joi.string().uuid().required(),
});
export const userQuerySchema = Joi.object({
search: Joi.string().optional(),
limit: Joi.number().integer().min(1).max(100).default(20),
offset: Joi.number().integer().min(0).default(0),
});
Mapping Joi Schemas to TypeScript Interfaces
While Joi validates at runtime, TypeScript interfaces enforce correct usage at compile time. You derive interfaces from your schemas to keep both in sync.
// src/types/userTypes.ts
export interface CreateUserPayload {
username: string;
email: string;
password: string;
role?: 'admin' | 'editor' | 'viewer';
}
export interface UserResponse {
id: string;
username: string;
email: string;
role: string;
createdAt: string;
}
export interface UserQueryParams {
search?: string;
limit?: number;
offset?: number;
}
export interface UserRouteParams {
userId: string;
}
Building Typed Route Handlers
Now wire everything together in a route configuration. The handler receives validated data, and TypeScript guarantees that you access only properties that actually exist on the validated payload.
// src/routes/userRoutes.ts
import Hapi from '@hapi/hapi';
import { createUserPayloadSchema, userIdParamsSchema, userQuerySchema } from '../schemas/userSchemas';
import type { CreateUserPayload, UserResponse, UserQueryParams, UserRouteParams } from '../types/userTypes';
// In-memory store for demonstration
const users: Map = new Map();
export const userRoutes: Hapi.ServerRoute[] = [
{
method: 'POST',
path: '/users',
options: {
validate: {
payload: createUserPayloadSchema,
},
response: {
status: {
201: {
description: 'User created successfully',
},
},
},
},
handler: (request: Hapi.Request, h: ResponseToolkit): Hapi.ResponseObject => {
// TypeScript now knows the shape of request.payload
const payload = request.payload as CreateUserPayload;
const newUser: UserResponse = {
id: crypto.randomUUID(),
username: payload.username,
email: payload.email,
role: payload.role ?? 'viewer',
createdAt: new Date().toISOString(),
};
users.set(newUser.id, newUser);
return h.response(newUser).code(201);
},
},
{
method: 'GET',
path: '/users',
options: {
validate: {
query: userQuerySchema,
},
},
handler: (request: Hapi.Request, h: Hapi.ResponseToolkit): Hapi.ResponseObject => {
const query = request.query as UserQueryParams;
const limit = query.limit ?? 20;
const offset = query.offset ?? 0;
const search = query.search?.toLowerCase();
let results = Array.from(users.values());
if (search) {
results = results.filter(
(user) =>
user.username.toLowerCase().includes(search) ||
user.email.toLowerCase().includes(search)
);
}
const paginated = results.slice(offset, offset + limit);
return h.response({
items: paginated,
total: results.length,
limit,
offset,
});
},
},
{
method: 'GET',
path: '/users/{userId}',
options: {
validate: {
params: userIdParamsSchema,
},
},
handler: (request: Hapi.Request, h: Hapi.ResponseToolkit): Hapi.ResponseObject => {
const params = request.params as UserRouteParams;
const user = users.get(params.userId);
if (!user) {
return h.response({ error: 'User not found' }).code(404);
}
return h.response(user).code(200);
},
},
{
method: 'DELETE',
path: '/users/{userId}',
options: {
validate: {
params: userIdParamsSchema,
},
},
handler: (request: Hapi.Request, h: Hapi.ResponseToolkit): Hapi.ResponseObject => {
const params = request.params as UserRouteParams;
const existed = users.delete(params.userId);
if (!existed) {
return h.response({ error: 'User not found' }).code(404);
}
return h.response().code(204);
},
},
];
By casting request.payload, request.query, and request.params to their respective interfaces, TypeScript provides full autocompletion and type checking inside every handler. If you attempt to access a property that doesn't exist on the interface, the compiler immediately reports an error.
Registering Routes on the Server
Update server.ts to import and register the typed routes.
// Updated section in src/server.ts
import { userRoutes } from './routes/userRoutes';
async function initServer(): Promise {
const server: Hapi.Server = Hapi.server({
host: serverConfig.host,
port: serverConfig.port,
router: {
isCaseSensitive: false,
stripTrailingSlash: true,
},
});
// Register the health-check route
server.route({
method: 'GET',
path: '/health',
handler: (_request: Hapi.Request, h: Hapi.ResponseToolkit) => {
return h.response({
status: 'ok',
timestamp: new Date().toISOString(),
}).code(200);
},
});
// Register all user routes
server.route(userRoutes);
return server;
}
Strongly Typed Plugins
Hapi's plugin system is one of its most powerful features. Plugins can add routes, decorate the server object, or extend request lifecycles. With TypeScript, you can define precise plugin interfaces so that consumers know exactly what a plugin provides.
Creating a Typed Plugin
// src/plugins/auditPlugin.ts
import Hapi from '@hapi/hapi';
interface AuditPluginOptions {
logDestination: 'console' | 'file';
logLevel: 'info' | 'debug' | 'warn' | 'error';
excludePaths?: string[];
}
// Augment the Hapi module to include our plugin's decorations
declare module '@hapi/hapi' {
interface ServerApplicationState {
auditLogCount: number;
}
interface RequestApplicationState {
requestStartTime: number;
}
}
export const auditPlugin: Hapi.Plugin = {
name: 'audit-plugin',
version: '1.0.0',
once: true,
register: async function (
server: Hapi.Server,
options: AuditPluginOptions
): Promise {
const { logDestination, logLevel, excludePaths = [] } = options;
// Initialize server-level state
server.app.auditLogCount = 0;
// Register a server extension point
server.ext('onRequest', (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
// Attach per-request metadata
request.app.requestStartTime = Date.now();
return h.continue;
});
server.ext('onPreResponse', (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
const path = request.path;
const method = request.method.toUpperCase();
// Skip excluded paths
if (excludePaths.some((excluded) => path.startsWith(excluded))) {
return h.continue;
}
const duration = Date.now() - request.app.requestStartTime;
server.app.auditLogCount += 1;
const logEntry = {
method,
path,
statusCode: (request.response as Hapi.ResponseObject)?.statusCode ?? 200,
durationMs: duration,
timestamp: new Date().toISOString(),
};
if (logDestination === 'console') {
const logFn = logLevel === 'error' ? console.error : console.log;
logFn(`[AUDIT] ${JSON.stringify(logEntry)}`);
}
// In production, you'd write to a file here
return h.continue;
});
},
};
Registering the Plugin with Type Safety
When you register the plugin, TypeScript enforces that you provide options matching the AuditPluginOptions interface.
// In server.ts, inside initServer():
await server.register({
plugin: auditPlugin,
options: {
logDestination: 'console',
logLevel: 'info',
excludePaths: ['/health'],
} as AuditPluginOptions,
});
If you omit a required option or provide an invalid value, TypeScript catches the mistake during compilationβbefore the server even starts.
Typed Lifecycle Hooks and Extension Points
Hapi provides a rich request lifecycle with extension points like onRequest, onPreAuth, onPostHandler, onPreResponse, and more. You can type these hooks to work seamlessly with your application's data structures.
// src/extensions/authExtension.ts
import Hapi from '@hapi/hapi';
import Boom from '@hapi/boom';
interface AuthenticatedUser {
userId: string;
username: string;
role: string;
}
// Augment Request interface to include authenticated user
declare module '@hapi/hapi' {
interface Request {
authenticatedUser?: AuthenticatedUser;
}
}
export function setupAuthExtension(server: Hapi.Server): void {
server.ext('onPreAuth', (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
// Simulate extracting user from an Authorization header
const authHeader = request.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.slice(7);
// In a real app, you'd verify the JWT here
// For demonstration, we decode a mock token
try {
const decoded = JSON.parse(Buffer.from(token, 'base64').toString('utf-8')) as AuthenticatedUser;
request.authenticatedUser = decoded;
} catch {
// Token is invalid; let the route's auth strategy handle it
}
}
return h.continue;
});
}
Now any route handler can access request.authenticatedUser with full type safety:
// In a route handler:
handler: (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
const user = request.authenticatedUser;
if (!user) {
return Boom.unauthorized('You must be logged in to access this resource');
}
// TypeScript knows user has userId, username, and role
console.log(`Request from ${user.username} (${user.role})`);
return h.response({ message: `Welcome, ${user.username}!` });
}
Typed Response Validation
Hapi can validate outgoing responses against Joi schemas. This ensures that your API never accidentally leaks extra fields or returns malformed data. Combined with TypeScript interfaces, you get a powerful dual-layer safety net.
// src/schemas/responseSchemas.ts
import Joi from '@hapi/joi';
export const userResponseSchema = Joi.object({
id: Joi.string().uuid().required(),
username: Joi.string().required(),
email: Joi.string().email().required(),
role: Joi.string().required(),
createdAt: Joi.date().iso().required(),
}).unknown(false); // .unknown(false) disallows extra fields
export const userListResponseSchema = Joi.object({
items: Joi.array().items(userResponseSchema).required(),
total: Joi.number().integer().required(),
limit: Joi.number().integer().required(),
offset: Joi.number().integer().required(),
});
Apply these schemas in your route options:
{
method: 'GET',
path: '/users/{userId}',
options: {
validate: {
params: userIdParamsSchema,
},
response: {
status: {
200: {
description: 'User found',
schema: userResponseSchema,
},
},
},
},
handler: (request: Hapi.Request, h: Hapi.ResponseToolkit): Hapi.ResponseObject => {
// ... handler logic
},
}
If the handler returns an object that doesn't match userResponseSchema, Hapi throws a validation error. In development mode, this catches bugs early; in production, you can configure Hapi to strip extra fields or log the mismatch without exposing details to clients.
Structuring a Large TypeScript Hapi Project
As your application grows, a clear project structure keeps code maintainable. Here is a recommended layout for a strongly typed Hapi application:
src/
βββ server.ts # Server entry point and composition
βββ config/
β βββ serverConfig.ts # Typed server configuration
βββ plugins/
β βββ auditPlugin.ts # Custom plugins with typed options
β βββ authPlugin.ts # Authentication plugin
βββ routes/
β βββ userRoutes.ts # User CRUD routes
β βββ healthRoutes.ts # Health check and metrics routes
βββ schemas/
β βββ userSchemas.ts # Joi validation schemas
β βββ responseSchemas.ts # Response validation schemas
βββ types/
β βββ userTypes.ts # TypeScript interfaces
β βββ commonTypes.ts # Shared utility types
βββ services/
β βββ userService.ts # Business logic layer
βββ extensions/
β βββ authExtension.ts # Request lifecycle extensions
βββ utils/
βββ helpers.ts # Shared utility functions
Each layer has a clear responsibility. Types live in types/, runtime validation in schemas/, route definitions in routes/, and business logic in services/. This separation prevents circular dependencies and makes unit testing straightforward.
Best Practices for Hapi TypeScript Applications
1. Keep Runtime Validation and Types in Sync
Your Joi schemas and TypeScript interfaces represent the same contractβone at runtime, one at compile time. When you update a payload shape, update both. Consider using tools like joi-to-typescript to auto-generate interfaces from Joi schemas in larger projects.
2. Use Module Augmentation Sparingly
Augmenting @hapi/hapi module declarations (as shown with Request.authenticatedUser) is powerful but can lead to confusion if multiple plugins augment the same interfaces. Document augmentations clearly and keep them centralized in a single types/augmentations.ts file.
3. Prefer Hapi.ServerRoute for Route Arrays
When exporting route arrays, type them as Hapi.ServerRoute[]. This ensures that every route object conforms to Hapi's expected shape and catches missing properties like method or path early.
4. Enable Strict TypeScript Options
Always enable "strict": true in tsconfig.json. This activates noImplicitAny, strictNullChecks, and other flags that catch common bugs. Combined with Hapi's runtime validation, strict mode creates a formidable safety net.
5. Type Error Responses with Boom
The @hapi/boom library provides typed HTTP error objects. Use it consistently for error responses rather than throwing plain objects:
import Boom from '@hapi/boom';
// Inside a route handler:
if (!resource) {
throw Boom.notFound('The requested resource does not exist');
}
if (!authorized) {
throw Boom.forbidden('You do not have permission to access this resource');
}
Boom errors integrate with Hapi's error lifecycle and produce consistent, well-typed error responses.
6. Leverage Server Decorations for Shared State
Instead of global variables, use server.app and request.app for typed shared state. Augment the corresponding interfaces so TypeScript knows what properties are available.
7. Write Tests with Type Safety
Use testing frameworks like Jest with ts-jest to write typed tests. Hapi's server.inject method is fully typed and lets you simulate requests without starting the actual server:
// src/__tests__/userRoutes.test.ts
import Hapi from '@hapi/hapi';
import { userRoutes } from '../routes/userRoutes';
describe('User Routes', () => {
let server: Hapi.Server;
beforeEach(async () => {
server = Hapi.server();
server.route(userRoutes);
await server.initialize();
});
it('should create a user and return 201', async () => {
const response = await server.inject({
method: 'POST',
url: '/users',
payload: {
username: 'johndoe',
email: 'john@example.com',
password: 'securePassword123',
role: 'editor',
},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.payload);
expect(body.username).toBe('johndoe');
expect(body.email).toBe('john@example.com');
expect(body.role).toBe('editor');
expect(body.id).toBeDefined();
});
it('should return 404 for a non-existent user', async () => {
const response = await server.inject({
method: 'GET',
url: '/users/00000000-0000-0000-0000-000000000000',
});
expect(response.statusCode).toBe(404);
});
});
TypeScript validates your test assertions and catches mismatched property names in payloads and expected responses.
Advanced Patterns: Generic Route Builders
For large APIs with repetitive CRUD patterns, you can create generic route builders that encapsulate common logic while preserving type safety.
// src/builders/crudBuilder.ts
import Hapi from '@hapi/hapi';
import Joi from '@hapi/joi';
interface CrudConfig {
resourceName: string;
basePath: string;
createSchema: Joi.ObjectSchema;
updateSchema: Joi.ObjectSchema;
idSchema: Joi.ObjectSchema;
listQuerySchema: Joi.ObjectSchema;
responseSchema: Joi.ObjectSchema;
service: {
create: (payload: CreatePayload) => Promise;
getById: (id: string) => Promise;
list: (query: any) => Promise<{ items: T[]; total: number }>;
update: (id: string, payload: UpdatePayload) => Promise;
delete: (id: string) => Promise;
};
}
export function buildCrudRoutes(
config: CrudConfig
): Hapi.ServerRoute[] {
return [
{
method: 'POST',
path: config.basePath,
options: {
validate: { payload: config.createSchema },
response: { status: { 201: { schema: config.responseSchema } } },
},
handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
const created = await config.service.create(request.payload as C);
return h.response(created).code(201);
},
},
{
method: 'GET',
path: `${config.basePath}/{id}`,
options: {
validate: { params: config.idSchema },
response: { status: { 200: { schema: config.responseSchema } } },
},
handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
const { id } = request.params as { id: string };
const item = await config.service.getById(id);
if (!item) throw Boom.notFound(`${config.resourceName} not found`);
return h.response(item).code(200);
},
},
{
method: 'GET',
path: config.basePath,
options: {
validate: { query: config.listQuerySchema },
},
handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
const result = await config.service.list(request.query);
return h.response(result).code(200);
},
},
{
method: 'PUT',
path: `${config.basePath}/{id}`,
options: {
validate: {
params: config.idSchema,
payload: config.updateSchema,
},
response: { status: { 200: { schema: config.responseSchema } } },
},
handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
const { id } = request.params as { id: string };
const updated = await config.service.update(id, request.payload as U);
if (!updated) throw Boom.notFound(`${config.resourceName} not found`);
return h.response(updated).code(200);
},
},
{
method: 'DELETE',
path: `${config.basePath}/{id}`,
options: {
validate: { params: config.idSchema },
},
handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
const { id } = request.params as { id: string };
const deleted = await config.service.delete(id);
if (!deleted) throw Boom.notFound(`${config.resourceName} not found`);
return h.response().code(204);
},
},
];
}
This builder preserves full type safety: the generic parameters T, C, and U flow from the schemas through to the service methods, ensuring that the payload types match the service expectations. You can generate entire resource endpoints with a single configuration object.
Handling Errors with Typed Lifecycle Methods
Hapi's request lifecycle includes onPreResponse, which is the ideal place to format errors consistently. You can create a typed error handler that transforms Boom errors and validation errors into a uniform API response.
// src/extensions/errorFormatter.ts
import Hapi from '@hapi/hapi';
import Boom from '@hapi/boom';
interface FormattedErrorResponse {
statusCode: number;
error: string;
message: string;
details?: Record;
timestamp: string;
}
export function setupErrorFormatter(server: Hapi.Server): void {
server.ext('onPreResponse', (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
const response = request.response;
// Only format error responses
if (!(response instanceof Error) && !Boom.isBoom(response)) {
return h.continue;
}
// Handle Boom errors
if (Boom.isBoom(response)) {
const boomResponse = response as Boom.Boom;
const formatted: FormattedErrorResponse = {
statusCode: boomResponse.output.statusCode,
error: boomResponse.output.payload.error,
message: boomResponse.output.payload.message,
timestamp: new Date().toISOString(),
};
return h.response(formatted).code(boomResponse.output.statusCode);
}
// Handle validation errors
if ((response as any).isJoi) {
const formatted: FormattedErrorResponse = {
statusCode: 400,
error: 'Validation Error',
message: (response as any).message || 'Invalid request parameters',
details: (response as any).details || {},
timestamp: new Date().toISOString(),
};
return h.response(formatted).code(400);
}
// Fallback for unhandled errors
const formatted: FormattedErrorResponse = {
statusCode: 500,
error: 'Internal Server Error',
message: 'An unexpected error occurred',
timestamp: new Date().toISOString(),
};
return h.response(formatted).code(500);
});
}
Register this extension early in your server initialization so it wraps all routes:
// In initServer():
setupErrorFormatter(server);
Conclusion
Combining Hapi with TypeScript elevates your API development from trusting runtime behavior to guaranteeing compile-time correctness. Strong typing permeates every layer of a Hapi application: server configuration, route parameters, payload validation, plugin options, lifecycle hooks, and even error formatting. By defining clear interfaces, augmenting Hapi's built-in types judiciously, and keeping runtime Joi schemas aligned with your TypeScript types, you build applications that are safer to refactor, easier to onboard new developers onto, and dramatically less prone to production surprises. The patterns covered in this tutorialβtyped routes, generic CRUD builders, plugin type augmentation, and structured error handlingβprovide a solid foundation for any Hapi TypeScript project, whether a small microservice or a sprawling enterprise API.