6min read

Stop copy-pasting! Use Typescript Mixins with Lucid Models

Leverage Typescript Mixins with your Lucid models to avoid repetitive code and share behaviors across your models.

If you've built more than a couple of models in AdonisJS, you've probably found yourself copy-pasting the same code again and again. CreatedAt/UpdatedAt, UUIDs, maybe a slug system... After seeing quite a few AdonisJS codebases with my work, I've noticed that often, a lot of models end up with duplicated code.

Good news: there's a clean solution for that. TypeScript has a mixin pattern that lets you compose reusable behaviors across your classes. Yet, I rarely see AdonisJS projects taking advantage of it. So here's a quick reminder: use mixins

What’s a mixin bro

A mixin is a function that takes a class and returns a new class extending it, adding extra properties or methods. With Lucid, this means you can compose your models with reusable behaviors: timestamps, UUIDs, soft deletes, whatever you want, without copy-pasting code in every model.

It's very similar to what some languages call Traits (for example, PHP, Scala, Rust, and others). If you come from one of those languages, think of mixins as the JS/TS equivalent of traits.

Mixins are a great example of composition over inheritance: you compose small, reusable behaviors together instead of creating a complex class hierarchy. Makes you code more modular and easier to maintain.

If you want to dive deeper into it, check out the official TypeScript Mixins documentation.

When using mixins with AdonisJS Lucid models, you'll often see a function called compose. The compose helper lets you use TypeScript class mixins with a much cleaner API.

Without compose, you have to nest your mixins, which quickly becomes hard to read:

class User extends UserWithAttributes(UserWithAge(UserWithPassword(UserWithEmail(BaseModel)))) {}

With compose, there's no nesting, and the order of mixins is clear (from top to bottom):

import { compose } from '@adonisjs/core/helpers'

class User extends compose(
  BaseModel,
  UserWithEmail,
  UserWithPassword,
  UserWithAge,
  UserWithAttributes
) {}

This makes your code much more readable and maintainable. The order of mixins is applied from left to right (or top to bottom), whereas with manual nesting, it's inside out.

My Go-To Mixins

Let's get concrete. Here are two mixins I use in almost every project. Nothing fancy, but they save me a ton of time and are great to illustrate the concept.

1. Timestamps

Having a createdAt and updatedAt field in every model is a common requirement. You probably have dozens of models that need this, which means a lot of boilerplate code inside every model that makes them harder to read and hides the unique parts of your model.

So let's extract that into a mixin:

import { column } from "@adonisjs/lucid/orm";
import type { DateTime } from "luxon";
import type { BaseModel } from "@adonisjs/lucid/orm";
import type { NormalizeConstructor } from "@adonisjs/core/types/helpers";

export const WithTimestamps = <Model extends NormalizeConstructor<typeof BaseModel>>(
  superclass: Model
) => {
  class WithTimestampsClass extends superclass {
    @column.dateTime({ autoCreate: true }) 
    declare createdAt: DateTime;

    @column.dateTime({ autoCreate: true, autoUpdate: true })
    declare updatedAt: DateTime;
  }
  return WithTimestampsClass;
};

2. UUID Primary Keys

Same deal if you're using UUIDs/CUIDs/ULIDs or any kind of identifier that should be generated outside the database.

import { v7 as randomUUID } from "uuid";
import { beforeCreate, column } from "@adonisjs/lucid/orm";
import type { BaseModel } from "@adonisjs/lucid/orm";
import type { NormalizeConstructor } from "@adonisjs/core/types/helpers";

export const WithPrimaryUuid = <Model extends NormalizeConstructor<typeof BaseModel>>(
  superclass: Model
) => {
  class WithPrimaryUuidClass extends superclass {
    static selfAssignPrimaryKey = true;

    @column({ isPrimary: true }) 
    declare id: string;

    @beforeCreate()
    static generateId(model: any) {
      model.id = randomUUID();
    }
  }
  return WithPrimaryUuidClass;
};

How to use them

Now that we have our basic mixins, let's slap them onto our models. Here's how you can use them in a Lucid model:

import { compose } from "@adonisjs/core/helpers";
import { BaseModel, column } from "@adonisjs/lucid/orm";
import { WithTimestamps } from "#core/mixins/with_timestamps";
import { WithPrimaryUuid } from "#core/mixins/with_primary_uuid";

export default class Team extends compose(
  BaseModel,
  WithTimestamps,
  WithPrimaryUuid
) {
  @column()
  declare name: string;
}

Personally, I'd much rather see this than a model with 20 lines of boilerplate for timestamps and UUIDs. It's cleaner and makes it easier to focus on what makes each model unique, not the plumbing.

Other Mixins Ideas

Of course, you can go way further than just timestamps and UUIDs. You can create mixins for all sorts of things. For example, slug generation, soft deletes, or even simple mixins that add relationships, like a WithOrganization mixin that adds a belongsTo + organizationId column to your model.

That said, don't overdo it. If you find yourself creating a mixin for every little thing, you might end up with a more complex system than you started with. Use them when it makes sense.

Conclusion

That's a wrap for this quick reminder. Time to refactor your models and make them cleaner with some pretty mixins!