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;
}
// blog-post-model.ts
import { Model } from "objection";
class BlogPostModel extends Model implements BlogPost {
static get tableName() {
return "blog_posts";
}
}
export default BlogPostModel;
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;
}
// 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;
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!
Top comments (14)
you can set
"strictPropertyInitialization": false,
in you tsconfig and don't have to use!
Dang, I had no idea! This is way better. Thank you for sharing, Hunter.
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:
(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?
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:
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:
I hope this helps. Cheers!
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!
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.
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:
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.
This is a hack, probably not recommended, but this can work:
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.
The code above will fly with Typescript, instead of it previously highlighting
post.undefined_property
...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
Very nice explanation. Looking 👀 forward to hearing more in the future. Welcome!
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!
Are you talking about Uncle Bob's book by the same name? I haven't read that one, do you recommend it?
thanks!