Extending the framework
This guide covers how to extend AdonisJS with custom functionality. You will learn how to:
- Add custom methods to framework classes using macros
- Create computed properties with getters
- Ensure type safety with TypeScript declaration merging
- Organize extension code in your application
- Extend specific framework modules like Hash, Session, and Authentication
Overview
AdonisJS provides a powerful extension system that lets you add custom methods and properties to framework classes without modifying the framework's source code. This means you can enhance the Request class with custom validation logic, add utility methods to the Response class, or extend any other framework class to fit your application's specific needs.
The extension system is built on two core concepts: macros (custom methods) and getters (computed properties). Both are added at runtime and integrate seamlessly with TypeScript through declaration merging, giving you full type safety and autocomplete in your editor.
This same extension API is used throughout AdonisJS's own first-party packages, making it a proven pattern for building reusable functionality. Whether you're adding a few helper methods for your application or building a package to share with the community, the extension system provides a clean, type-safe way to enhance the framework.
Why extend the framework?
Before diving into the mechanics, let's understand when and why you'd want to extend framework classes.
Without extensions, you'd need to write the same logic repeatedly across your application. For example, checking if a request expects JSON responses:
export default class PostsController {
async index({ request, response }: HttpContext) {
// Repeated in every action that returns different formats
const acceptHeader = request.header('accept', '')
const wantsJSON = acceptHeader.includes('application/json') ||
acceptHeader.includes('+json')
if (wantsJSON) {
return response.json({ posts: [] })
}
return view.render('posts/index')
}
}
With a macro, you write this logic once and use it everywhere:
import { Request } from '@adonisjs/core/http'
/**
* Check if the request expects a JSON response based on Accept header
*/
Request.macro('wantsJSON', function (this: Request) {
const firstType = this.types()[0]
if (!firstType) {
return false
}
return firstType.includes('/json') || firstType.includes('+json')
})
export default class PostsController {
async index({ request, response }: HttpContext) {
if (request.wantsJSON()) {
return response.json({ posts: [] })
}
return view.render('posts/index')
}
}
Extensions are ideal when you:
- Have framework-specific logic reused across your application
- Want to maintain AdonisJS's fluent API style
- Are building a package that integrates deeply with the framework
- Need type-safe custom functionality with autocomplete support
Understanding macros and getters
Before we start adding extensions, let's clarify what macros and getters are and when to use each.
Macros are custom methods you add to a class. They work like regular methods and can accept parameters, perform computations, and return values. Use macros when you need functionality that requires input or performs actions.
Getters are computed properties that look like regular properties when you access them. They're calculated on-demand and can optionally cache their result. Use getters for read-only derived data that doesn't require parameters.
Both macros and getters use declaration merging, a TypeScript feature that extends existing type definitions to include your custom additions. This ensures your extensions have full type safety and autocomplete support.
Under the hood, AdonisJS uses the macroable package to implement this functionality. If you want to understand the implementation details, you can refer to that package's documentation.
Creating your first macro
Let's build a simple macro step-by-step. We'll add a method to the Request class that checks if the incoming request is from a mobile device.
-
Create the extensions file
Create a dedicated file to hold all your framework extensions. This keeps your extension code organized in one place.
src/extensions.ts// This file contains all framework extensions for your applicationThe file can be named anything you like, but
extensions.tsclearly communicates its purpose. -
Import the class you want to extend
Import the framework class you want to add functionality to. For our example, we'll extend the Request class.
src/extensions.tsimport { Request } from '@adonisjs/core/http' -
Add the macro method
Use the
macromethod to add your custom functionality. The method receives the class instance asthis, giving you access to all the class's existing properties and methods.src/extensions.tsimport { Request } from '@adonisjs/core/http' Request.macro('isMobile', function (this: Request) { /** * Get the User-Agent header, defaulting to empty string if not present */ const userAgent = this.header('user-agent', '') /** * Check if the User-Agent contains common mobile identifiers */ return /mobile|android|iphone|ipad|phone/i.test(userAgent) })The
function (this: Request)syntax is important because it gives you the correctthiscontext. Don't use arrow functions here, as they don't preserve thethisbinding. -
Add TypeScript type definitions
Tell TypeScript about your new method using declaration merging. Add this at the end of your extensions file.
src/extensions.tsdeclare module '@adonisjs/core/http' { interface Request { isMobile(): boolean } }The module path in
declare modulemust exactly match the import path you use. The interface name must exactly match the class name. -
Load extensions in your provider
Import your extensions file in a service provider's
bootmethod to ensure the extensions are registered when your application starts.providers/app_provider.tsexport default class AppProvider { async boot() { await import('../src/extensions.ts') } } -
Use your macro
Your macro is now available throughout your application with full type safety and autocomplete.
app/controllers/home_controller.tsimport type { HttpContext } from '@adonisjs/core/http' export default class HomeController { async index({ request, view }: HttpContext) { /** * TypeScript knows about isMobile() and provides autocomplete */ if (request.isMobile()) { return view.render('mobile/home') } return view.render('home') } }
Creating your first getter
Getters are computed properties that work like regular properties but are calculated on-demand. Let's add a getter to the Request class that provides a cleaned version of the request path.
import { Request } from '@adonisjs/core/http'
Request.getter('cleanPath', function (this: Request) {
/**
* Get the current URL path
*/
const path = this.url()
/**
* Remove trailing slashes and convert to lowercase
*/
return path.replace(/\/+$/, '').toLowerCase()
})
declare module '@adonisjs/core/http' {
interface Request {
cleanPath: string // Note: property, not a method
}
}
Notice the type declaration differs from macros. Getters are properties, not methods, so you don't include () in the type definition.
You can use getters like regular properties:
export default class LogMiddleware {
async handle({ request, logger }: HttpContext, next: NextFn) {
/**
* Access the getter like a property, not a method
*/
logger.info('Request path: %s', request.cleanPath)
await next()
}
}
Getter callbacks cannot be async because JavaScript getters are synchronous by design. If you need async computation, use a macro instead.
Singleton getters
By default, getters recalculate their value every time you access them. For expensive computations, you can make a getter a singleton, which caches the result after the first calculation.
import { Request } from '@adonisjs/core/http'
/**
* The third parameter (true) makes this a singleton getter
*/
Request.getter('ipAddress', function (this: Request) {
/**
* Check for proxy headers first, fall back to direct IP
* This only runs once per request instance
*/
return this.header('x-forwarded-for') ||
this.header('x-real-ip') ||
this.ips()[0] ||
this.ip()
}, true)
declare module '@adonisjs/core/http' {
interface Request {
ipAddress: string
}
}
With singleton getters, the function executes once per instance of the class, and the return value is cached for that instance:
const ip1 = request.ipAddress // Executes the getter function
const ip2 = request.ipAddress // Returns cached value, doesn't re-execute
const ip3 = request.ipAddress // Still returns cached value
Use singleton getters when the computed value won't change during the instance's lifetime. For example, a request's IP address won't change during a single HTTP request, so caching it makes sense.
Don't use singleton getters for values that might change, like computed properties based on mutable state.
When to use macros vs getters
Choosing between macros and getters depends on your use case. Here's a practical guide.
Use macros when you need to:
- Accept parameters
- Perform actions with side effects
- Return different values based on input
- Execute async operations
/**
* Macro example: Accepts a role parameter
*/
Request.macro('hasRole', function (this: Request, role: string) {
const user = this.ctx.auth.user
return user?.role === role
})
// Usage: request.hasRole('admin')
Use getters when you need to:
- Provide computed read-only properties
- Calculate derived data from existing properties
- Cache expensive computations (with singleton)
- Maintain a property-like API
/**
* Getter example: Computed property with no parameters
*/
Request.getter('isAuthenticated', function (this: Request) {
return this.ctx.auth.isAuthenticated
})
// Usage: request.isAuthenticated
Both can coexist on the same class. Choose based on the API you want to provide.
Understanding declaration merging
Declaration merging is how TypeScript learns about your runtime extensions. Getting this right is crucial for type safety.
The module path in your declare module statement must exactly match the path you use to import the class:
// If you import like this:
import { Request } from '@adonisjs/core/http'
// You must declare like this (exact same path):
declare module '@adonisjs/core/http' {
interface Request {
isMobile(): boolean
}
}
Why this matters: TypeScript uses the module path to determine which type definition to merge with.
What happens: If the paths don't match, TypeScript won't recognize your extension. You'll see errors like "Property 'isMobile' does not exist on type 'Request'" even though your code runs correctly.
Solution: Always copy the exact import path when writing your declaration:
// ✅ Correct: Paths match
import { Request } from '@adonisjs/core/http'
declare module '@adonisjs/core/http' { ... }
// ❌ Wrong: Paths don't match
import { Request } from '@adonisjs/core/http'
declare module '@adonisjs/http-server' { ... }
You can declare multiple extensions in the same declare module block:
declare module '@adonisjs/core/http' {
interface Request {
isMobile(): boolean
hasRole(role: string): boolean
cleanPath: string
ipAddress: string
}
}
Or split them across multiple blocks if you prefer:
declare module '@adonisjs/core/http' {
interface Request {
isMobile(): boolean
}
}
declare module '@adonisjs/core/http' {
interface Request {
hasRole(role: string): boolean
}
}
Both approaches work identically. Choose based on your organization preferences.
Common mistakes
Here are the most common issues developers encounter when extending the framework and how to fix them.
Mistake: Using arrow functions for macros
Why it fails: Arrow functions don't have their own this binding, so you can't access the class instance.
// ❌ Wrong: Arrow function
Request.macro('isMobile', () => {
return this.header('user-agent') // `this` is undefined!
})
// ✅ Correct: Regular function
Request.macro('isMobile', function (this: Request) {
return this.header('user-agent') // `this` is the Request instance
})
Mistake: Forgetting the singleton parameter defaults to false
What happens: Your getter recalculates every time it's accessed, even if the value won't change.
// This executes the function every single time
Request.getter('expensiveCalculation', function (this: Request) {
return someExpensiveOperation()
})
// Add true for singleton to cache the result
Request.getter('expensiveCalculation', function (this: Request) {
return someExpensiveOperation()
}, true) // Caches after first access
Mistake: Treating getters like methods
What happens: You'll get errors because getters are properties, not functions.
Request.getter('ipAddress', function (this: Request) {
return this.ip()
})
// ❌ Wrong: Calling it like a method
const ip = request.ipAddress()
// ✅ Correct: Accessing it like a property
const ip = request.ipAddress
Macroable classes
The following framework classes support macros and getters. Each entry includes the import path and typical use cases.
Application
The main application instance. Extend this to add application-level utilities.
Common use cases: Add custom environment checks, application state getters, or global configuration accessors.
Example: Add a getter to check if the app is running in a specific mode.
import app from '@adonisjs/core/services/app'
app.getter('isProduction', function () {
return this.inProduction
})
Request
The HTTP request class. Extend this to add request validation or parsing logic.
Common use cases: Add methods for checking request characteristics, parsing custom headers, or validating request types.
Example: Add a method to check if the request is an AJAX request.
import { Request } from '@adonisjs/core/http'
Request.macro('isAjax', function (this: Request) {
return this.header('x-requested-with') === 'XMLHttpRequest'
})
Response
The HTTP response class. Extend this to add custom response methods or formatters.
Common use cases: Add methods for sending formatted responses, setting common headers, or handling specific response types.
Example: Add a method for sending paginated JSON responses.
import { Response } from '@adonisjs/core/http'
Response.macro('paginated', function (this: Response, data: any, meta: any) {
return this.json({ data, meta })
})
HttpContext
The HTTP context class passed to route handlers and middleware. Extend this to add context-level utilities.
Common use cases: Add helpers that combine request and response logic, or add shortcuts for common operations.
Example: Add a method to get the current user or fail.
import { HttpContext } from '@adonisjs/core/http'
HttpContext.macro('getCurrentUser', async function (this: HttpContext) {
return await this.auth.getUserOrFail()
})
Route
Individual route instances. Extend this to add custom route configuration methods.
Common use cases: Add methods for applying common middleware patterns, setting route metadata, or configuring routes in specific ways.
Example: Add a method to mark routes as requiring authentication.
import { Route } from '@adonisjs/core/http'
Route.macro('protected', function (this: Route) {
return this.middleware('auth')
})
RouteGroup
Route group instances. Extend this to add custom group-level configuration.
Common use cases: Add methods for applying common patterns to groups of routes.
Example: Add a method to apply API versioning to a group.
import { RouteGroup } from '@adonisjs/core/http'
RouteGroup.macro('apiVersion', function (this: RouteGroup, version: number) {
return this.prefix(`/api/v${version}`)
})
RouteResource
Resourceful route instances. Extend this to customize resource route behavior.
Common use cases: Add methods for customizing which resource routes are created or adding resource-specific middleware.
BriskRoute
Brisk (quick) route instances used for simple route definitions. Extend this for shortcuts.
Common use cases: Add convenience methods for quick route configurations.
ExceptionHandler
The global exception handler. Extend this to add custom error handling methods.
Common use cases: Add methods for handling specific error types or formatting error responses.
Example: Add a method to handle validation errors consistently.
import { ExceptionHandler } from '@adonisjs/core/http'
ExceptionHandler.macro('handleValidationError', function (error: any) {
return this.ctx.response.status(422).json({ errors: error.messages })
})
MultipartFile
Uploaded file instances. Extend this to add file validation or processing methods.
Common use cases: Add methods for validating file types, processing images, or generating thumbnails.
Example: Add a method to check if a file is an image.
import { MultipartFile } from '@adonisjs/core/bodyparser'
MultipartFile.macro('isImage', function (this: MultipartFile) {
return this.type?.startsWith('image/')
})
Extending specific modules
Beyond macros and getters, many AdonisJS modules provide dedicated extension APIs for adding custom implementations. These are designed for more complex integrations like custom drivers or loaders.
The following modules can be extended with custom implementations:
- Creating a custom hash driver - Add support for custom password hashing algorithms
- Creating a custom session store - Store sessions in custom backends like MongoDB or Redis
- Creating a custom social auth driver - Add OAuth providers beyond the built-in ones
- Adding custom REPL methods - Extend the REPL with custom commands
- Creating a custom translations loader - Load translations from custom sources
- Creating a custom translations formatter - Format translations with custom logic
These extension points go beyond simple methods and properties, allowing you to deeply integrate custom functionality into the framework.
Next steps
Now that you understand how to extend the framework, you can:
- Learn about Service Providers to organize extension code in packages
- Explore Dependency Injection to understand how the container works
- Read about Testing to learn how to test your extensions
- Study the first-party packages to see real-world extension examples