DEV Community

loading...

Using a TypeScript interface to define model properties in Objection.js

tylerlwsmith profile image Tyler Smith Updated on ・2 min read

I'm working on a full-stack TypeScript project using Next.js, and I'm trying to share TypeScript definitions between Node and React. I'm using Objection.js as an ORM to talk to the database, and it has great built-in TypeScript support.

Ideally, I'd like to define an interface once and use that on both the client and the server. Making this work with Objection proved to be more challenging than I had hoped.

My first attempt

I originally tried making an interface, and then implementing that interface on my Objection.js Model. Here's what that looked like:

// types.d.ts
interface BlogPost {
  id: number;
  title?: string;
  content?: string;
  slug?: string;
}
Enter fullscreen mode Exit fullscreen mode
// blog-post-model.ts
import { Model } from "objection";

class BlogPostModel extends Model implements BlogPost {
  static get tableName() {
    return "blog_posts";
  }
}

export default BlogPostModel;
Enter fullscreen mode Exit fullscreen mode

Unfortunately, this doesn't work that well. TypeScript gives the following error:

Class 'BlogPostModel' incorrectly implements interface 'BlogPost'. Property 'id' is missing in type 'BlogPostModel' but required in type 'BlogPost'.ts(2420)

TypeScript is upset because it doesn't see any of the properties that were defined in the interface implemented on the model, and it doesn't know that those properties will be added magically by Objection.

To make this solution work, I would need to define all of my model's properties again on the Objection model. This would mean that every time I change the interface, I'd also have to change the model, even though it's just going to have the exact same properties. Yikes.

A better solution: declaration merging

Among TypeScript's numerous and wonky features is declaration merging. There's a lot to unpack with this feature, so if you want to get a solid understanding of its capabilities then read the docs.

One feature that does not appear to be in the documentation is class/interface merging (found in this GitHub comment). If you have an interface with the same name as a class that is in the same file, class/interface merging will automatically merge the interface properties with the class by the same name.

Here is my revised code that takes advantage of class/interface merging:

// types.d.ts
interface BlogPost {
  id: number;
  title?: string;
  content?: string;
  slug?: string;
}
Enter fullscreen mode Exit fullscreen mode
// blog-post-model.ts
import { Model } from "objection";

interface BlogPostModel extends BlogPost {}

class BlogPostModel extends Model {
  static get tableName() {
    return "blog_posts";
  }
}

export default BlogPostModel;
Enter fullscreen mode Exit fullscreen mode

Because we gave the BlogPostModel interface the same name as the class, it automatically merges the interface properties with the class, and it gives us all of TypeScript's autocomplete goodness without having to redefine interface properties on the Objection model.

Also, take a look at the comments below, there are some good suggested alternatives to this approach. Do you know a better way to do this? If so, let me know in the comments below!

Discussion (12)

pic
Editor guide
Collapse
htunnicliff profile image
Hunter Tunnicliff
import { Model, ModelObject } from "objection";

export default class Movie extends Model {
  static tableName = "movies";

  id!: number;
  title!: string;
  release_date!: Date;
}

// The `ModelObject` generic gives you a clean interface
// that can be used on the frontend, without any of the
// objection Model class properties or methods.
type MovieShape = ModelObject<Movie>;
Enter fullscreen mode Exit fullscreen mode
Collapse
tylerlwsmith profile image
Tyler Smith Author

Dang, I had no idea! This is way better. Thank you for sharing, Hunter.

Collapse
vsamma profile image
Veiko Samma • Edited

Hi,

I found a similar solution a few months ago (don't remember the source though). It's especially good as I don't want to use default exports.

Instead of having an identically named interface to extend your original interface, you must export the class first (which extends Model) and then the new interface which extends both your actual interface and the Model class:

// blog-post-model.ts
import { Model } from "objection";
import { BlogPost } from "./blogpost";

export class BlogPostModel extends Model {
  static get tableName() {
    return "blog_posts";
  }
}

export interface BlogPostModel extends BlogPost, Model {}
Enter fullscreen mode Exit fullscreen mode

(Sorry, new user on dev.to, didn't know how to present code and went with JSFiddle.)

But either way, this solution in this article or my approach, both cause problems when you want to start using relatedQuery.

For example:

const result = await BlogPostModel.relatedQuery('images').for(1); would return this error:

Property 'for' does not exist on type 'never'.ts(2339)

Have you come across this problem and if yes, how would you solve it?

Collapse
tylerlwsmith profile image
Tyler Smith Author

Hey Veiko, welcome to DEV!

If you're just trying to use types within the back-end Node.js app, it may be best to define your properties directly on the model. Here's a simplified example from Objection's official TypeScript example project:

import { Model } from 'objection'
import Person from './Person'

export class Movie extends Model {
  // Table name is the only required property.
  static tableName = 'movies'
  id!: number
  name!: string
  actors!: Person[]
}
Enter fullscreen mode Exit fullscreen mode

This should give you types and autocomplete. Objection makes this example project hard to find because it's at the bottom of the homepage with no fanfare.

If you want to share those types with a front-end application, take a look at Hunter's example further up in the comments.

If you want to leave a comment with code on DEV, you can use three backticks (`) followed by the language, the code, then three more to close the code block. It should look like this in your comment box:

```js
console.log("Hello world!");
```
Enter fullscreen mode Exit fullscreen mode

I hope this helps. Cheers!

Collapse
vsamma profile image
Veiko Samma

Hi Tyler!

Thanks for the really helpful and quick response :) I really appreciate it.

So I also posted this question on Objection github and got a response that I can't do both those things I wanted to do. That indeed, for the "relatedQuery" to work, you have to have the properties defined in the Model class.

So I am currently trying to change all my backend logic accordingly, but before I get it all done and can test stuff, my first concern would be this:

How to handle the case when I have multiple interfaces for one entity? An easy example based on your BlogPost here is for read/write, let's say your full BlogPost interface would have a property called "createdDate" but you don't want that to appear in the interface which is used for creating blog posts because you don't want anybody to accidentally enter this value themselves during blog post creation.
Or another example, for user creation you want to have a password field but when you return a user object to the FE, you don't want to pass the password value back (even if it's hashed at that time).
I get that actually in JS you can add more properties to an object compared to its interface anyways and to be perfectly safe not to add any unwanted data to your DB, you should clean the object before that (or just cherry pick the fields from the input object you need) but still, when our FE devs work on their tasks, they only want to work with an interface for entity creation that has all the required fields and not wonder whether they should fill "creationDate" or similar fields on FE themselves.

So would the best solution then be to define such an interface, have it as an input to a service function and then manually create a DB Model object, map all the required fields from input to model and then insert that model object to the DB?

Btw, here's the link to the github issue, Sami Koskimäki suggested me to just create that separate interface and make the model Implement that. This does create some problems with Partial objects which didn't seem to occur with this Hunter's type approach, but then again having a separate interface seems to be a more decoupled approach.

I'll try them both and see which one works better for me.

Thanks for your help!

Thread Thread
tylerlwsmith profile image
Tyler Smith Author

I hope you're able to get it sorted out! Feel free to leave a comment with which one you liked better incase anyone else who is having the same problem stumbles onto this conversation in the future.

Thread Thread
vsamma profile image
Veiko Samma • Edited

Thanks!
I will.

But first I need to solve some issues. With this "type" solution offered here, I'm immediately running into problems when creating new objects of this new type "Dataset" (based on DatasetModel).

Although it hides most of Model's properties, it still includes QueryBuilderType and when instantiating a new object, this is a required field to add as well. If I don't, I get this error:

Type ... is missing the following properties from type 'ModelObject<DatasetModel>': QueryBuilderType

Edit:

And when I have an example like this:

//project.ts
export class ProjectModel extends Model {
  public id!: number;
  public name!: string;
  public datasets!: DatasetModel[];

  public static tableName = 'projects';

  static get relationMappings() {
    return {
      datasets: {
        relation: Model.HasManyRelation,
        modelClass: DatasetModel,
        join: {
          from: 'projects.id',
          to: 'datasets.project_id',
        },
      }
    };
  }
}

export type Project = ModelObject<ProjectModel>;


//dataset.ts
export class DatasetModel extends Model {
  public id!: number;
  public projectId!: number;

  public static tableName = 'datasets';

  static get relationMappings() {
    return {
      project: {
        relation: Model.BelongsToOneRelation,
        modelClass: ProjectModel,
        join: {
          from: 'datasets.project_id',
          to: 'projects.id',
        },
      },
  }
}

export type Dataset = ModelObject<DatasetModel>;
Enter fullscreen mode Exit fullscreen mode

I'm running into a problem when I want to instantiate a Project object. I need the "datasets" property to be defined for the Project so I can use it on the object (with autocomplete etc), only relationMapping is not enough. But when its type is DatasetModel (which is the only way relatedQuery works), then I also need to explicitly set the ~30 properties that the Model class has as well. Not to mention the fact that then you need to use the DatasetModel class in your business logical (service) layer which means that the DB communication (ORM) implementational details are not decoupled from the rest of the app anymore.

If I use Hunter's solution with the Type and set the "datasets" property to type "Dataset" for the Project class, then I only need to set the value to 1 extra property (QueryBuilderType, as stated above), but this again breaks the usage of relatedQuery and I'm back to square one.

So I think now I have to start trying with the interface "implements" approach.

But it's so crazy how you can't easily define DB model entities, which have other entities as related properties, all based on interface contracts in Typescript and also use all the query functionality properly.

Collapse
kwameopareasiedu profile image
Kwame Opare Asiedu

This is a hack, probably not recommended, but this can work:

class BlogPost extends Model {
    id:number;
    title: string;
    content: string;
    slug: string;
    [k: string]: any;

    static get tableName() {
        return "blog_posts";
    }
}
Enter fullscreen mode Exit fullscreen mode

Upside,

You can add your database columns as part of the model and your IDE should pick them up

Downside

Because of the [k: string]: any property, Typescript will not warn if an undefined property was accessed on instances of BlogPost.

I.e.

const post = await BlogPost.query().findById(1);
const someImportantVariable = post.undefined_property;
Enter fullscreen mode Exit fullscreen mode

The code above will fly with Typescript, instead of it previously highlighting post.undefined_property ...

Collapse
atwright147 profile image
Andy Wright

I am finding that combining this with an interface (shared with the UI) helps to keep them in sync. The interface will cause errors if your model is missing any fields

class BlogPost extends Model implements IBlog {
    ...
}
Enter fullscreen mode Exit fullscreen mode
Collapse
codefinity profile image
Manav Misra

Very nice explanation. Looking 👀 forward to hearing more in the future. Welcome!

Collapse
mattcasey profile image
Matt Casey

I like your solution more than Hunter's because I am trying to follow Clean Architecture, and want my Objection models to reference domain entities, rather than the other direction. Thanks!

Collapse
tylerlwsmith profile image
Tyler Smith Author

Are you talking about Uncle Bob's book by the same name? I haven't read that one, do you recommend it?