Skip to content

Instantly share code, notes, and snippets.

@stctheproducer
Forked from lncitador/README.md
Created June 25, 2025 11:19
Show Gist options
  • Save stctheproducer/49a21694b29d75755c68331da12cb450 to your computer and use it in GitHub Desktop.
Save stctheproducer/49a21694b29d75755c68331da12cb450 to your computer and use it in GitHub Desktop.
AdonisJS Lucid Mapper Utility

This abstract Mapper class provides a flexible and type-safe way to serialize, transform, and label AdonisJS Lucid models and their paginated results. It is designed to help you build clean API responses and DTOs in AdonisJS projects.

Features

  • Supports serialization of single models, arrays, and paginated results.
  • Custom serialization logic via subclassing.
  • Utility for picking specific fields and relations from models.
  • Label mapping mechanism for enums or field names.

Use this utility to keep your API layer clean, consistent, and maintainable when working with AdonisJS and Lucid ORM.


How to use:
Extend the Mapper class in your own mappers, override the serialize and label methods as needed, and use the static mapTo and label methods to transform your models for API responses.


Distributed for the AdonisJS community. Contributions and feedback are welcome!

import type { LucidRow, LucidModel, ModelAttributes } from '@adonisjs/lucid/types/model'
import type {
ExtractModelRelations,
HasMany,
ManyToMany,
BelongsTo,
HasOne,
HasManyThrough,
ModelRelations,
} from '@adonisjs/lucid/types/relations'
import type { DateTime } from 'luxon'
/**
* Converte campos DateTime para string em um objeto
*/
type Serialized<T> = Prettify<{
[K in keyof T]: T[K] extends DateTime | null ? string | null : T[K]
}>
/**
* ShapeMapper é um tipo utilitário que mapeia as colunas e relações de um modelo Lucid
* em uma estrutura organizada com $columns e $relations
*/
export type ShapeMapper<Model extends LucidRow> = {
$columns: Serialized<ModelAttributes<Model>>
$relations: Omit<
{
[K in ExtractModelRelations<Model>]: Model[K] extends
| HasMany<infer M>
| ManyToMany<infer M>
| HasManyThrough<infer M>
? Serialized<ModelAttributes<InstanceType<M>>>[]
: Model[K] extends HasOne<infer M> | BelongsTo<infer M>
? Serialized<ModelAttributes<InstanceType<M>>>
: never
},
'type'
>
}
export type Prettify<T> = {
[K in keyof T]: T[K]
} & {}
export type PageProps = {
total: number
perPage: number
currentPage: number
lastPage: number
firstPage: number
firstPageUrl: string
lastPageUrl: string
nextPageUrl: string
previousPageUrl: string
}
export type Paginate<T> = {
data: T[]
meta: PageProps
}
export type PickColumn<Model extends LucidModel | ModelRelations<any, any>> =
Model extends LucidModel
? keyof ModelAttributes<InstanceType<Model>> & string
: Model extends HasOne<infer M> | BelongsTo<infer M>
? keyof ModelAttributes<InstanceType<M>> & string
: Model extends HasMany<infer M> | ManyToMany<infer M> | HasManyThrough<infer M>
? keyof ModelAttributes<InstanceType<M>> & string
: never
export type PickRelations<Model extends LucidModel | ModelRelations<any, any>> = {
[K in keyof ShapeMapper<
Model extends LucidModel
? InstanceType<Model>
: Model extends HasOne<infer M> | BelongsTo<infer M>
? InstanceType<M>
: Model extends HasMany<infer M> | ManyToMany<infer M> | HasManyThrough<infer M>
? InstanceType<M>
: never
>['$relations']]: readonly (keyof (ShapeMapper<
Model extends LucidModel
? InstanceType<Model>
: Model extends HasOne<infer M> | BelongsTo<infer M>
? InstanceType<M>
: Model extends HasMany<infer M> | ManyToMany<infer M> | HasManyThrough<infer M>
? InstanceType<M>
: never
>['$relations'][K] extends (infer R)[]
? R
: ShapeMapper<
Model extends LucidModel
? InstanceType<Model>
: Model extends HasOne<infer M> | BelongsTo<infer M>
? InstanceType<M>
: Model extends HasMany<infer M> | ManyToMany<infer M> | HasManyThrough<infer M>
? InstanceType<M>
: never
>['$relations'][K]))[]
}
export type PickShape<
Model extends LucidModel | ModelRelations<any, any>,
Columns extends PickColumn<Model>,
Relations extends Partial<PickRelations<Model>> = {},
> = Prettify<
Pick<
ShapeMapper<
Model extends LucidModel
? InstanceType<Model>
: Model extends HasOne<infer M> | BelongsTo<infer M>
? InstanceType<M>
: Model extends HasMany<infer M> | ManyToMany<infer M> | HasManyThrough<infer M>
? InstanceType<M>
: never
>['$columns'],
Extract<
Columns,
keyof ShapeMapper<
Model extends LucidModel
? InstanceType<Model>
: Model extends HasOne<infer M> | BelongsTo<infer M>
? InstanceType<M>
: Model extends
| HasMany<infer M>
| ManyToMany<infer M>
| HasManyThrough<infer M>
? InstanceType<M>
: never
>['$columns']
>
> & {
[K in keyof Relations &
keyof ShapeMapper<
Model extends LucidModel
? InstanceType<Model>
: Model extends HasOne<infer M> | BelongsTo<infer M>
? InstanceType<M>
: Model extends
| HasMany<infer M>
| ManyToMany<infer M>
| HasManyThrough<infer M>
? InstanceType<M>
: never
>['$relations']]: ShapeMapper<
Model extends LucidModel
? InstanceType<Model>
: Model extends HasOne<infer M> | BelongsTo<infer M>
? InstanceType<M>
: Model extends HasMany<infer M> | ManyToMany<infer M> | HasManyThrough<infer M>
? InstanceType<M>
: never
>['$relations'][K] extends (infer R)[]
? // @ts-expect-error
Pick<R, Relations[K][number]>[]
: Pick<
ShapeMapper<
Model extends LucidModel
? InstanceType<Model>
: Model extends HasOne<infer M> | BelongsTo<infer M>
? InstanceType<M>
: Model extends
| HasMany<infer M>
| ManyToMany<infer M>
| HasManyThrough<infer M>
? InstanceType<M>
: never
>['$relations'][K],
// @ts-expect-error
Relations[K][number]
>
}
>

Mapper Utility Example

The Mapper class helps you serialize, transform, and label your AdonisJS Lucid models for clean API responses.

import type Company from '#models/company'
import { type Infer, Mapper } from '#utils/mapper'
import { SortBy } from '#utils/sort_by'
import { UserMapper } from './user.js'

export class CompanyMapper extends Mapper<Company> {
    public label() {
        return {
            id: 'ID',
            companyName: 'Company Name',
            corporateReason: 'Corporate Name',
            companyEmail: 'Email',
            phone: 'Phone',
            address: 'Address',
            neighborhood: 'Neighborhood',
            city: 'City',
            state: 'State',
            description: 'Description',
            rating: 'Rating',
            logo: 'Logo',
            createdAt: 'Created at',
            updatedAt: 'Updated at',
        }
    }

    public serialize(input: Company) {
        return {
            id: input.id,
            verified: input.verified,
            document: input.document,
            companyName: input.companyName,
            corporateReason: input.corporateReason,
            companyEmail: input.companyEmail,
            description: input.description,
            phone: input.phone,
            address: input.address,
            neighborhood: input.neighborhood,
            state: input.state,
            city: input.city,
            cityCode: input.cityCode,
            site: input.site,
            facebook: input.facebook,
            instagram: input.instagram,
            tiktok: input.tiktok,
            whatsapp: input.whatsapp,
            rating: input.rating,
            logo: input.logo || '',
            members: UserMapper.mapTo(input.members),
            blocks: this.pick(input.blocks, [
                'id',
                'isActive',
                'createdBy',
                'creator',
                'description',
                'deactivator',
                'deactivatedBy',
                'deactivatedAt',
                'createdAt',
                'updatedAt',
            ]),
            type: input.type,
            identityUrl: input.identityUrl,
            identityStatus: input.identityStatus,
            registrationCompletedAt: input.registrationCompletedAt?.toJSON(),
            acceptTermsAt: input.acceptTermsAt.toJSON(),
            identityStatusPendingAt: input.identityStatusPendingAt?.toJSON(),
            identityStatusApprovedAt: input.identityStatusApprovedAt?.toJSON(),
            identityStatusRejectedAt: input.identityStatusRejectedAt?.toJSON(),
        }
    }
}

// Serialize a single model instance
const company = await Company.findOrFail(1)
const serialized = CompanyMapper.mapTo(company, true)

// Serialize a paginated result
const companies = await Company.query().paginate(1, 10)
const serializedList = CompanyMapper.mapTo(companies)

// Get a label for a field
const label = CompanyMapper.label('companyName') // "Company Name"

// Headers for a data table
const header = ({ column }: HeaderContext<any, unknown>) => {
    return h(DataTableColumnHeader, {
        column,
        label: (value) => CompanyMapper.label(value),
    })
}

This demonstrates how to serialize single models, paginated results, pick fields, and get labels using your Mapper class.

import type { LucidModel, LucidRow, ModelPaginatorContract } from '@adonisjs/lucid/types/model'
import type { Maybe } from '#utils/maybe'
import type { Paginate, ShapeMapper, PickColumn, PickRelations, PickShape } from '#types/common'
import { ModelRelations } from '@adonisjs/lucid/types/relations'
export type Serialize<T> = T extends LucidRow ? ShapeMapper<T>['$columns'] : T
type Mappeable = Record<string, unknown>
type Serializable = LucidRow | Mappeable
type Labelable = Record<string, string>
/**
* Utility type to infer the output type of a Mapper's serialize method.
* If the Mapper has a custom serialize method, it uses that; otherwise, it defaults to Serialize<T>.
*/
type InferOutputType<
MapperType extends Mapper<I, any>,
I extends Serializable,
> = MapperType extends {
serialize(input: I): infer R
}
? R
: I extends LucidRow
? ShapeMapper<I>['$columns']
: I extends Record<string, any>
? I
: never
type MapperOutput<T extends Mapper<any, any>> =
T extends Mapper<infer E, any> ? InferOutputType<T, E> : never
/**
* Utility for mapping and serializing Lucid ORM entities to plain objects or other formats.
* Provides methods to transform models, arrays, paginations, and also to map labels.
*/
export abstract class Mapper<E extends Serializable, L extends Labelable = any> {
/**
* Maps an input (model, array, or pagination) to the output format defined by the Mapper.
* The return type is automatically inferred based on the input type's nullability.
*/
static mapTo<I extends LucidRow, P extends Mapper<I, any>>(
this: new () => P,
input: ModelPaginatorContract<I>
): Paginate<MapperOutput<P>>
static mapTo<I extends LucidRow, P extends Mapper<I, any>, T extends Maybe<I>>(
this: new () => P,
input: T
): T extends null | undefined ? null : MapperOutput<P>
static mapTo<I extends LucidRow, P extends Mapper<I, any>>(
this: new () => P,
input: I[]
): MapperOutput<P>[]
static mapTo<I extends Mappeable, P extends Mapper<I, any>, T extends Maybe<I>>(
this: new () => P,
input: T
): T extends null | undefined ? null : MapperOutput<P>
static mapTo<I extends Mappeable, P extends Mapper<I, any>>(
this: new () => P,
input: I[]
): MapperOutput<P>[]
static mapTo(this: MapperConstructor, input: unknown) {
if (Array.isArray(input)) {
if ('toJSON' in input && typeof input.toJSON === 'function') {
const serialized = input.toJSON()
if ('meta' in serialized) {
const instance = new this()
const data = serialized.data.map((item: any) => instance.#serialize(item))
return {
data,
meta: {
total: serialized.meta.total,
perPage: serialized.meta.perPage,
currentPage: serialized.meta.currentPage,
lastPage: serialized.meta.lastPage,
firstPage: serialized.meta.firstPage,
firstPageUrl: serialized.meta.firstPageUrl,
lastPageUrl: serialized.meta.lastPageUrl,
nextPageUrl: serialized.meta.nextPageUrl,
previousPageUrl: serialized.meta.previousPageUrl,
},
}
}
}
return input.map((item) => new this().#serialize(item))
}
return input ? new this().#serialize(input) : null
}
/**
* Optional method to override the serialization of an entity.
*/
protected serialize?(input: E): any
/**
* Internal serialization method that handles the actual transformation of entities.
*/
#serialize(input: E) {
if (!input) throw new Error('Serializable input is required')
if (this.serialize) return this.serialize(input)
if (typeof input === 'object') {
if ('serialize' in input && typeof input.serialize === 'function') {
return input.serialize()
}
if ('toJSON' in input && typeof input.toJSON === 'function') {
return input.toJSON()
}
}
return JSON.parse(JSON.stringify(input))
}
/**
* Optional method to provide custom labels for fields.
*/
protected label?(): L
/**
* Returns the label corresponding to a key, if defined, or the key itself.
*/
static label<MapperType extends Mapper<any, any>>(
this: new () => MapperType,
value: MapperType extends Mapper<any, infer R> ? keyof R : string
): string
static label<MapperType extends Mapper<any, any>>(
this: new () => MapperType,
value: string
): string
static label(this: MapperConstructor, value: any) {
return new this().#label(value)
}
/**
* Returns the label corresponding to a key, if defined, or the key itself (instance).
*/
#label(value: keyof L): string
#label(value: string): string
#label(value: any) {
return this.label?.()[value] || value
}
/**
* Serializes only the selected fields of a model or array of models.
*/
pick<
T extends LucidModel | ModelRelations<any, any>,
K extends PickColumn<T>,
R extends Partial<PickRelations<T>>,
>(input: T | null, keys: K[], relations?: R): PickShape<T, K, R> | null
pick<
T extends LucidModel | ModelRelations<any, any>,
K extends PickColumn<T>,
R extends Partial<PickRelations<T>>,
>(input: T[], keys: K[], relations?: R): PickShape<T, K, R>[]
pick(input: any | any[] | null, keys: string[], relations: any = {}) {
if (!input) return null
if (Array.isArray(input)) {
return input.map((i) => {
if ('serialize' in i) {
return i.serialize({ fields: { pick: keys }, relations })
}
return i
})
}
if ('serialize' in input) {
return input.serialize({ fields: { pick: keys }, relations })
}
return input
}
}
/**
* Helper type that represents a Mapper constructor.
*/
type MapperConstructor = new (...args: any) => Mapper<any, any>
/**
* Utility type to infer the output type of a Mapper class.
*/
export type Infer<T extends MapperConstructor> = T extends new () => infer MapperInstance
? MapperInstance extends { serialize(input: any): infer R }
? R
: MapperInstance extends Mapper<infer E, any>
? Serialize<E>
: never
: never

Maybe Utility Usage Example

The Maybe utility provides a fluent and type-safe way to handle values that may be null or undefined in TypeScript. It helps you avoid common pitfalls and makes your code more robust.

Basic Usage

import { Maybe } from '#types/maybe'
import { Company } from '#models/company'

// Wrap a value that might be null or undefined
const maybeName = Maybe('Alice')
const maybeNull = Maybe(null)
const maybeUndefined = Maybe(undefined)

// Check if a value is present

// Check if a value exists
if (maybeName.isSome()) {
  console.log('Name exists:', maybeName.value)
}

// Provide a default if value is missing
const name = maybeNull.getOrElse('Default Name') // "Default Name"

// Map over the value if it exists
const upper = maybeName.map((n) => n.toUpperCase()) // "ALICE"
const missing = maybeNull.map((n) => n.toUpperCase()) // null

// Use optionalMap to get undefined if missing
const opt = maybeNull.optionalMap((n) => n.length) // undefined

// Chain with nullishMap to get null if missing
const nullish = maybeUndefined.nullishMap((n) => n.length) // null

// Use mapOrElse to provide a fallback
const length = maybeName.mapOrElse((n) => n.length, 0) // 5
const missingLength = maybeNull.mapOrElse((n) => n.length, 0) // 0

This demonstrates the main features: wrapping, checking, mapping, and providing defaults for maybe values.

/**
* A utility type that ensures specific properties of an object
* are both required and non-nullable.
*/
export type SetAlwaysRequired<T, K extends keyof T> = Omit<T, K> &
Required<{ [P in K]: NonNullable<T[P]> }>
/**
* A utility type that represents a value which may or may not exist.
* It explicitly models the possibility that a value could be
* `null` or `undefined`.
*/
export type Maybe<T> = T | null | undefined
export type AsyncMaybe<T> = Promise<Maybe<T>>
class MaybeEntity<T> {
#value: Maybe<T>
private constructor(value: Maybe<T>) {
this.#value = value
}
static from<T>(value: Maybe<T>): MaybeEntity<T>
static from<T>(value: Maybe<T>, ...values: Maybe<T>[]): MaybeEntity<T>
static from<T>(value: Maybe<T>, ...values: Maybe<T>[]): MaybeEntity<T> {
for (const val of [value, ...values]) {
if (MaybeEntity.isSome(val)) return new MaybeEntity(val)
}
return new MaybeEntity(null as Maybe<T>)
}
public isSome(): boolean {
return MaybeEntity.isSome(this.#value)
}
public isNone(): boolean {
return MaybeEntity.isNone(this.#value)
}
public get value(): T {
return MaybeEntity.getValue(this.#value)
}
public getOrElse(defaultValue: T): T {
return MaybeEntity.getOrElse(this.#value, defaultValue)
}
/**
* Se o valor existe, aplica a função e o retorna, caso contrário, retorna `null`
* @see MaybeEntity.optionalMap
*/
public map<U>(fn: (value: T) => NonNullable<U>): Maybe<U> {
return MaybeEntity.map(this.#value, fn)
}
/**
* Se o valor existe, aplica a função e o retorna, caso contrário, retorna `undefined`
* @see MaybeEntity.map
*/
public optionalMap<U>(fn: (value: T) => U): U | undefined {
return MaybeEntity.optionalMap(this.#value, fn)
}
/**
* Se o valor existe, aplica a função e o retorna, caso contrário, retorna `null`
* @see MaybeEntity.map
*/
public nullishMap<U>(fn: (value: T) => U): U | null {
return MaybeEntity.optionalMap(this.#value, fn, null)
}
public mapOrElse<U>(fn: (value: T) => U, defaultValue: NonNullable<U>): NonNullable<U> {
return MaybeEntity.mapOrElse(this.#value, fn, defaultValue)
}
static isSome<T>(value: Maybe<T>): value is T {
return value !== null && value !== undefined
}
static isNone<T>(value: Maybe<T>): value is null | undefined {
return !MaybeEntity.isSome(value)
}
static getValue<T>(value: Maybe<T>): T {
if (MaybeEntity.isNone(value)) {
throw new Error('Cannot get value of None')
}
return value as T
}
static getOrElse<T>(value: Maybe<T>, defaultValue: T): T {
return MaybeEntity.isSome(value) ? (value as T) : defaultValue
}
static map<T, U>(value: Maybe<T>, fn: (value: T) => U): Maybe<U> {
return MaybeEntity.isSome(value) ? fn(value as T) : null
}
static optionalMap<I, O, F = undefined>(
value: Maybe<I>,
fn: (value: I) => O,
fallback?: F
): O | F {
if (MaybeEntity.isSome(value)) return fn(value as I)
return typeof fallback !== 'undefined' ? fallback : (undefined as F)
}
static mapOrElse<T, U>(
value: Maybe<T>,
fn: (value: T) => U,
defaultValue: NonNullable<U>
): NonNullable<U> {
if (MaybeEntity.isNone(value)) return defaultValue
const result = fn(value as T)
if (Maybe.isSome(result)) return result as NonNullable<U>
return defaultValue
}
}
export function Maybe<T>(value: Maybe<T>, ...values: Maybe<T>[]): MaybeEntity<T> {
return MaybeEntity.from(value, ...values)
}
Maybe.from = MaybeEntity.from
Maybe.isSome = MaybeEntity.isSome
Maybe.isNone = MaybeEntity.isNone
Maybe.getValue = MaybeEntity.getValue
Maybe.getOrElse = MaybeEntity.getOrElse
Maybe.map = MaybeEntity.map
Maybe.optionalMap = MaybeEntity.optionalMap
Maybe.mapOrElse = MaybeEntity.mapOrElse
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment