DEV Community

Thetos
Thetos

Posted on • Updated on

Typing Sequelize association mixins using TypeScript

When using the Sequelize ORM package, it is highly likely you need to create associations between models at some point. Using the example models:

import {
  Model,
  type InferAttributes,
  type InferCreationAttributes,
} from "sequelize";

class Foo extends Model<InferAttributes<Foo>, InferCreationAttributes<Foo>> {
  declare id: number;
  // ...
}

class Bar extends Model<InferAttributes<Bar>, InferCreationAttributes<Bar>>{
  declare id: number;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

You can then associate them using one of the association methods on the model:

Foo.belongsTo(Bar); // creates one-to-one relation, FK is part of Foo
Enter fullscreen mode Exit fullscreen mode

Now Foo is associated to Bar.

The Mixins

As per Sequelize's documentation:

When an association is defined between two models, the instances of those models gain special methods to interact with their associated counterparts.

So, when calling Model.belongsTo (or any other association methods), some methods are added to the instances of said Model. Now, you know these methods exist, but TypeScript does not. So how would we type these methods?

Typing Mixins: The "easy" way

The @types/sequelize package used to get types for Sequelize is kind to us by providing pre-declared generic types for each of the mixin methods, using these, you could type the methods like this:

class Foo extends Model<InferAttributes<Foo>, InferCreationAttributes<Foo>> {
  // ...

  // declare methods (implementations are defined by Sequelize)
  declare getBar: BelongsToGetAssociationMixin<Bar>;
  declare setBar: BelongsToSetAssociationMixin<Bar, number>;
  declare createBar: BelongsToCreateAssociationMixin<Bar>;
}
Enter fullscreen mode Exit fullscreen mode

Now this is easy and straightforward, but this method has a handful of issues:

  • Each of the mixins have to be declared for each association for completeness sake. This means that the model's body might end up being bloated with dozens of mixin declarations as associations are added.
  • Typing each and every mixin type, even for just one association is tedious (even with autocomplete).
  • Many-to-many associations create a total of 10 mixin methods each!
  • All the mixin method types need to be imported, adding bloat to your file imports.
  • What happens when an association is changed? You need to change or remove each declaration.

So this method is clearly not ideal, it bloats the code, makes it less maintainable by needing to remember to change many lines altogether... Can we do better?

Typing Mixins: The practical way

We have seen that typing each mixin method individually in the model definition is not ideal, so how can we refactor the typings to type every mixin at once for an association?

Well first, we need to reason about an association's mixins, so let's extract them to a type:

type FooBarMixins = {
  getBar: BelongsToGetAssociationMixin<Bar>;
  setBar: BelongsToSetAssociationMixin<Bar, number>;
  createBar: BelongsToCreateAssociationMixin<Bar>;
}
Enter fullscreen mode Exit fullscreen mode

Ok, we have the mixins all together now, can we refactor it further? Maybe make a generic type for belongsTo associations? There are a few types we could use for configuration like the model and the type of the model's primary key!...

But each of the property names are derived from the association name we give Sequelize (derived from the other model's name when unspecified). Thankfully, this automatic naming follows a few rules we could use too!

We know we can make the property types configurable easily using generics, but can we make the property names configurable too?

the following sections tagged as "experiment" are steps I took when trying to solve the issue, which led to the solution, if you only care about the conclusion, feel free to skip them

Experiment: Making a type with a configurable property name

Let's experiment a bit and make a type which only has one configurable property. For that, we can use template literal types for basic concatenation of string types, something like this maybe?

type SetMixin<Name extends string> = {
  [`set${Capitalize<Name>}`]: any; // we'll get back to the property type later
}
Enter fullscreen mode Exit fullscreen mode

error TS1170: A computed property name in a type literal must refer to an expression whose type is a literal type or a 'unique symbol' type.

Ah... this syntax expects a value between the brackets, which may only be a literal or symbol. We need a syntax that would accept a type and create a property from it, also what would happen if Name was a union of string literal types? Maybe it should create multiple properties, `set${Capitalize<Name>}`, would already be a string union according to TypeScript's rules on template
literal types...

I get it! we need to use a mapped type!

type SetMixin<Name extends string> = {
  [_ in `set${Capitalize<Name>}`]: any;
}
Enter fullscreen mode Exit fullscreen mode

No error, if we test it with:

type SetFoo = SetMixin<"foo">
//    ^? type SetFoo = { setFoo: any; }
Enter fullscreen mode Exit fullscreen mode

That works! Now let's how do we combine multiple configurable properties

Experiment: Combining configurable properties

Now let's try making multiple configurable properties in a single type, as we know we need at least 3 to reach our goal. Let's try putting the mapping syntax multiple times in the same object type!

type BelongsToMixin<Name extends string> = {
  [_ in `set${Capitalize<Name>}`]: any;
  [_ in `get${Capitalize<Name>}`]: any;
}
Enter fullscreen mode Exit fullscreen mode

error TS7061: A mapped type may not declare properties or methods.

Huh? Well turns out TypeScript thinks the second mapping is trying to declare a property using brackets, like we tried earlier, and we cannot declare properties in a mapped type. So we could do two, but how do we combine them?

Considering mapped type A and B, our new type needs to be both A and B, which is a type intersection!

type BelongsToMixin<Name extends string> = {
  [_ in `set${Capitalize<Name>}`]: any;
} & {
  [_ in `get${Capitalize<Name>}`]: any;
}
Enter fullscreen mode Exit fullscreen mode

Again, we get no error, and it just works as expected!

But that pile of syntax, while it shows that what we want is possible, is a bit annoying to write. Can we refactor that into something more useable?

Property name postfixing type

Let's write a simple-ish type that will allow us to write:

type BelongsToMixin<Name extends string> = PostfixProperties<
  {
    get: any;
    set: any;
    create: any;
  },
  Capitalize<Name>
>;

type FooBelongsToBar = BelongsToMixin<"bar">;
//     ^? type FooBelongsToBar = { getBar: any; setBar: any; createBar: any; }
Enter fullscreen mode Exit fullscreen mode

The magic lies in the generic PostfixProperties type, so how do we write it anyway? We use mapped types once again, and their ability to rename the output object type's properties:

type PostfixProperties<PropTypes, Postfix extends string>= {
  [P in keyof PropTypes as `${Exclude<P, symbol>}${Postfix}`]: PropTypes[P];
};
Enter fullscreen mode Exit fullscreen mode

Let's take a minute to dissect this type. First, it is a mapped type over the keys of the PropTypes type parameter, however, we rename the properties to include the original name and append the provided Postfix. When concatenating, we need to exclude symbol properties of our original object since they cannot appear in a template literal type. Finally, the type of the renamed property is set to its original type.

Defining the mixin interface type

Let's get back to the original task at hand: typing Sequelize mixin methods. For that we needed a generic type to define the mixin methods with appropriate names and types. We now have all the pieces we need, so let's just do that:

type BelongsToMixin<
  AssociatedModel extends Model,
  PrimaryKeyType,
  Name extends string,
> = PostfixProperties<
  {
    get: BelongsToGetAssociationMixin<AssociatedModel>;
    set: BelongsToSetAssociationMixin<AssociatedModel, PrimaryKeyType>;
    create: BelongsToCreateAssociationMixin<AssociatedModel>;
  },
  Capitalize<Name>
>;

type FooBelongsToBar = BelongsToMixin<Bar, number, "foo">;
//    ^? type FooBelongsToBar = { getFoo: ...; setFoo: ...; createFoo: ...; }
Enter fullscreen mode Exit fullscreen mode

And it works, we have our generic type for a belongsTo association's mixins! hurray!

The definition of the hasOne other association types are left as an exercise for the reader.

For the many-to-many relation types, not a lot changes, but first, let's define a Prettify type:

type Prettify<T> = { [P in keyof T]: T[P]; };
Enter fullscreen mode Exit fullscreen mode

This types does not change the type of T so long as it is an object type, but it helps typescript tooling show intersections of object types as a single object type. Speaking of object intersection, we will use it to come up with a hasMany association mixin type:

type HasManyMixin<
  AssociatedModel extends Model,
  PrimaryKeyType,
  SingularName extends string,
  PluralName extends string,
> = Prettify<
  PostfixProperties<
    {
      get: HasManyGetAssociationsMixin<AssociatedModel>;
      count: HasManyCountAssociationsMixin;
      has: HasManyHasAssociationsMixin<AssociatedModel, PrimaryKeyType>;
      set: HasManySetAssociationsMixin<AssociatedModel, PrimaryKeyType>;
      add: HasManyAddAssociationsMixin<AssociatedModel, PrimaryKeyType>;
      remove: HasManyRemoveAssociationsMixin<AssociatedModel, PrimaryKeyType>;
    },
    Capitalize<PluralName>
  > &
    PostfixProperties<
      {
        has: HasManyHasAssociationMixin<AssociatedModel, PrimaryKeyType>;
        add: HasManyAddAssociationMixin<AssociatedModel, PrimaryKeyType>;
        remove: HasManyRemoveAssociationMixin<AssociatedModel, PrimaryKeyType>;
        create: HasManyCreateAssociationMixin<AssociatedModel>;
      },
      Capitalize<SingularName>
    >
>;
Enter fullscreen mode Exit fullscreen mode

The mixin type for belongsToMany associations is left as an exercise, because it is basically the same.

Using mixin interface types

Now we can generate mixin interface types, but how do we use it with our models?

Our models are defined as JS classes extending from the base Model class from sequelize, in TypeScript we can also add an implements clause for additional interfaces. "But, our types are not defined as interfaces, we cannot implement them?" you may ask, however in TypeScript, you can actually implement any type that is "interface-like", that is, types that represent things describable by interfaces, so our object type alias (or intersection of object types) is useable as an interface.

class Foo
  extends Model<InferAttributes<Foo>, InferCreationAttributes<Foo>>
  implements BelongsToMixin<Bar, number, "Bar"> {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

So that's it, right? Wrong. Because implements checks the instance type of the class (according to its body and parent hierarchy), and finds the properties we said existed do not. So what do we do? Do we give in and actually write all the bloat we didn't want, with the only benefit of having it type-checked? We have tools to do that, oh and we have to make sure each and every one of them is a declare-ed property because we do not control the implementation...

Well, while it would still be better, we don't have to, thanks to properties of the class syntax and interfaces in TypeScript.

When defining a class in TypeScript, you actually define two things at once: the run-time class value, and the class instance type. So when class Foo {} is defined, we have both a local Foo value, which is the class constructor, and a Foo type for the class instances. The class instance type becomes interesting to us since it is not defined like a type alias, but like an interface.

Interfaces in TypeScript have the interesting behavior called "interface merging", where if two interfaces of the same name are defined in the same scope, the properties of both declarations merge and leaves only one type with all the properties. We can also have interfaces extend others, and multiple ones at a time, to inherit their properties. With this we can effectively add properties to any interface we want.

Combining these, we can emulate a sort of "declare implements" functionality like so:

interface Foo extends BelongsToMixin<Bar, number, "Bar"> {}
class Foo extends Model<InferAttributes<Foo>, InferCreationAttributes<Foo>> {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

With that, all our instances will have the correctly typed mixin methods, and we didn't have to bloat our class definition with declarations to add them.

Full Final Example Code

The code is also available as a TypeScript playground

import {
  Model,
  type InferAttributes,
  type InferCreationAttributes,
  type BelongsToGetAssociationMixin,
  type BelongsToSetAssociationMixin,
  type BelongsToCreateAssociationMixin,
  type HasManyGetAssociationsMixin,
  type HasManyCountAssociationsMixin,
  type HasManyHasAssociationsMixin,
  type HasManySetAssociationsMixin,
  type HasManyAddAssociationsMixin,
  type HasManyRemoveAssociationsMixin,
  type HasManyHasAssociationMixin,
  type HasManyAddAssociationMixin,
  type HasManyRemoveAssociationMixin,
  type HasManyCreateAssociationMixin,
} from "sequelize";

// define helper types
type PostfixProperties<PropTypes, Postfix extends string> = {
  [P in keyof PropTypes as `${Exclude<P, symbol>}${Postfix}`]: PropTypes[P];
};

type Prettify<T> = { [P in keyof T]: T[P] };

// association mixin interfaces
type BelongsToMixin<
  AssociatedModel extends Model,
  PrimaryKeyType,
  Name extends string,
> = PostfixProperties<
  {
    get: BelongsToGetAssociationMixin<AssociatedModel>;
    set: BelongsToSetAssociationMixin<AssociatedModel, PrimaryKeyType>;
    create: BelongsToCreateAssociationMixin<AssociatedModel>;
  },
  Capitalize<Name>
>;

type HasManyMixin<
  AssociatedModel extends Model,
  PrimaryKeyType,
  SingularName extends string,
  PluralName extends string,
> = Prettify<
  PostfixProperties<
    {
      get: HasManyGetAssociationsMixin<AssociatedModel>;
      count: HasManyCountAssociationsMixin;
      has: HasManyHasAssociationsMixin<AssociatedModel, PrimaryKeyType>;
      set: HasManySetAssociationsMixin<AssociatedModel, PrimaryKeyType>;
      add: HasManyAddAssociationsMixin<AssociatedModel, PrimaryKeyType>;
      remove: HasManyRemoveAssociationsMixin<AssociatedModel, PrimaryKeyType>;
    },
    Capitalize<PluralName>
  > &
    PostfixProperties<
      {
        has: HasManyHasAssociationMixin<AssociatedModel, PrimaryKeyType>;
        add: HasManyAddAssociationMixin<AssociatedModel, PrimaryKeyType>;
        remove: HasManyRemoveAssociationMixin<AssociatedModel, PrimaryKeyType>;
        create: HasManyCreateAssociationMixin<AssociatedModel>;
      },
      Capitalize<SingularName>
    >
>;

// define models
interface Foo extends BelongsToMixin<Bar, number, "Bar"> {}
class Foo extends Model<InferAttributes<Foo>, InferCreationAttributes<Foo>> {
  declare id: number;
  // ...
}

class Bar extends Model<InferAttributes<Bar>, InferCreationAttributes<Bar>> {
  declare id: number;
  // ...
}

// define association(s)
Foo.belongsTo(Bar);

// create instance
const foo = Foo.build({ id: 42 });

// use typed mixin!
foo.createBar({ id: 88 });

Enter fullscreen mode Exit fullscreen mode

Top comments (0)