Bezerra's Blog

Avoiding missing relations when working with TypeORM

Posted on 4/21/2023 - 9 min read
Last updated on 4/21/2023

TypeORM is a TypeScript ORM library that enables developers to manage database relationships and perform database operations without writing SQL statements. With TypeORM, developers can interact with relational databases using object-oriented programming principles. This is achieved by modeling entities and their relationships using JavaScript's classes and properties. It simplifies the database management process and provides a more intuitive approach to working with databases.

After using TypeORM for over a year at Justos, we discovered that it poses some challenges in maintaining safe types while still offering a good developer experience. In this article, we explore a robust approach to safely handling relations in TypeORM using TypeScript's advanced type system. We will walk you through a step-by-step guide on utilizing TypeScript's mapped types, conditional types, and utility functions to enforce correct typing, minimize redundancy in null-checks, and streamline your codebase. At the end of this article, you will see a typing proposal that leverages TypeScript's advanced features to ensure your ORM relations are loaded safely and efficiently, leading to more reliable and maintainable code.

The problem

Consider the following example:

// Author.ts
@Entity()
export class Author extends BaseEntity {
  @PrimaryGeneratedColumn()
  id!: number

  @Column({
    type: 'varchar',
    unique: true,
  })
  username!: string

  @OneToMany(() => Post, (post) => post.author)
  posts!: Post[]
}

// Post.ts
@Entity()
export class Post extends BaseEntity {
  @PrimaryGeneratedColumn('uuid')
  uuid!: string

  @ManyToOne(() => Author, (author) => author.posts)
  @JoinColumn()
  author!: Author

  @Column()
  content!: string
}

In the example above Post.author is a relation in TypeORM that is not automatically loaded unless you flag it as eager or explicitly ask for it when quering for the post, like so:

function getPosts(): Promise<Post[]> {
  return Post.find({
    relations: ['author'],
  })
}

So now, when fetching and passing a post around, I have a few options on how to deal with the relation that might or might not be loaded yet:

Eager

With this option, TypeORM loads all relations in a single query. This option is the default behavior when loading relations, but it can cause performance issues if you have many related entities.

@ManyToOne(() => Author, (author) => author.posts, { eager: true })
@JoinColumn()
author!: Author

When you know you will always need two entities together, it can make sense to use this, but it can cause performance issues if you have many related entities. This can also cascade into hell if you eagerly load an entity that eagerly loads more entities and so on.

Lazy

With this option, TypeORM loads relations on-demand, when properties are accessed for the first time. This can improve performance by reducing the number of queries. But it comes at the expense of developer experience. See the example below:

// Post.ts
@ManyToOne(() => Author, (author) => author.posts, { lazy: true })
@JoinColumn()
author!: Promise<Author>

// example of usage:
async function readPost() {
  const post = await Post.findOne(...)
  ...
  const author = await post.author
  ...
}

If the property is flagged as lazy-loaded, it becomes a Promise and so every time you want to access it, you need to await it first. This becomes annoying very quickly as you try to use the property on simple things like creating objects or mapping through the results.

You will notice that in the example at the start of this section, there is a typing error. Here is a refresher:

// Post.ts
@Entity()
export class Post extends BaseEntity {
  
  // other properties and methods

  @ManyToOne(() => Author, (author) => author.posts)
  @JoinColumn()
  author!: Author
    
  // ...
}

The relation Post.author should be typed as author: Author | null = null, as it is not flagged as eager or lazy. This is because if you query posts without explicitly requesting the "author" relation, the property will be null at runtime, and its type should reflect that.

async function getUsername(postId: string) {
  const post = await Post.findOne(postId, { relations: ['author']})
  ...
  return post.author?.username
}

Unfortunately, typing this correctly creates another developer experience issue. Every time you want to use the author property, you will need to perform a null-check first or use null-chaining, even when you know the property was loaded.

This misstyping makes developers think that a property is never null and so they will never null-check or null-chain them, causing errors at runtime. This errors is what we started calling "Missing Relations Error".

Having identified the issue with managing relations in TypeORM, we now turn our attention to the constraints our team faced when trying to find a suitable solution.

Constraints

During the team discussion, we established several constraints. First, we didn't want to switch the relations to lazy-loaded since it would lead to a poor developer experience, with the need to await the promise every time. Additionally, we had flows in the code where we needed to choose specific relations to be loaded while excluding others.

We also aimed to eliminate incorrect property typing, which caused several errors such as Cannot read properties of null and Cannot read properties of undefined. However, we didn't want to scatter obj?.prop and if (obj?.prop) throughout the codebase. Ideally, if the outer code checks an object's property for null, this information should be carried forward when passing the object down to other methods.

Finally, we aimed for the solution to reflect on the type system and type errors.

With these constraints in mind, we set out to devise a solution that would prevent issues caused by missing relations.

Solution proposed

The solution below assumes that we don't want to lazy or eagerly load our relations (at least, not all of them), and therefore a way to protect ourselves from missing relations is needed. The goal is to safeguard the code from missing relations and prevent redundant null-checks for properties that have already been checked in outer scopes.

Initial solution

Consider the following function that prints the username of an author of a given Post. Post is provided as an argument for the function and so, given Post.author type, the function doesn't it can be nullable:

// Post.ts
@Entity()
export class Post extends BaseEntity {
  
  // other properties and methods

  @ManyToOne(() => Author, (author) => author.posts)
  @JoinColumn()
  author!: Author
   
  // ...
}

// example:
function printAuthorUsername(post: Post): void {
  console.log(post.author.username)
}

The first thing to do is to properly type our author property as nullable. Remember that it is, in fact, nullable, since you can load a Post from the database without author. This will make our console.log line report an error Object is possibly 'undefined'.. This is what we want. We want to use types to our advantage and make Typescript work for us and let us know when we might be doing something wrong.

Next, we could do a null check on post.author to solve the problem, but instead, we’ll change the type of the argument the method expects and make the method-caller have to deal with the null checks. The code would look like this:

// Post.ts
@Entity()
export class Post extends BaseEntity {
  
  // other properties and methods

  @ManyToOne(() => Author, (author) => author.posts)
  @JoinColumn()
  author: Author | null = null
  
  // ...
}

function printAuthorUsername(post: Post & { author: Author }): void {
  console.log(post.author.username)
}

Let’s define a type for our Post with relations, while also leveraging Typescript’s Utility Types:


type RequiredPick<T, K extends keyof T> = { [P in K]: NonNullable<T[P]> }
type PostWithRelations = Post & RequiredPick<Post, 'author'>

function printAuthorUsername(post: PostWithRelations): void {
  // ...
}

The type RequiredPick is a merge between NonNullable<Type>, Required<Type> and Pick<Type, Keys>. It creates a subset with keys K of any type T. All keys in the subset object are non-nullable, which means that null and undefined are removed of their possible values.

The type PostWithRelations uses our custom RequiredPick to tell Typescript that the method expects a parameter of type Post that also happens to have an author property that cannot be null or undefined. Now, all method callers will have an error similar to this:

Argument of type 'Post' is not assignable to parameter of type 'PostWithRelations'.
  Type 'Post' is not assignable to type 'RequiredPick<Post, "author">'.
    Types of property 'author' are incompatible.
      Type 'Author | null' is not assignable to type 'Author'.
        Type 'null' is not assignable to type 'Author'.ts(2345)

We can solve this error and make Typescript happy by writing a small Type-Guard:

// user-defined type-guard
function postHasAuthor(p: Post): p is PostWithRelations {
  return Boolean(p.author)
}

export async function main() : Promise<void> {
  const post = await Post.findOneOrFail('1234', { relations: ['author'] })

  if (postHasAuthor(post)) { // <-- using the type-guard
    printAuthorUsername(post) // post is PostWithRelations inside the IF block
  }
}

By using the type-guard to check if the post has a non-nullable property author and returning a type that represents that, we tell Typescript that if the type-guard function returns true, then whatever Post object we passed to it must be of type PostWithRelations. Now we can call our printAuthorUsername without worrying that it might not receive the relation it needs because the method itself expressed through types which relations from Post it needs.

The final code can look something like this:

@Entity()
export class Post extends BaseEntity {
  
  // other properties and methods

  @ManyToOne(() => Author, (author) => author.posts)
  @JoinColumn()
  author: Author | null = null
  
  // ...
}

type RequiredPick<T, K extends keyof T> = { [P in K]: NonNullable<T[P]> }
type PostWithRelations = Post & RequiredPick<Post, 'author'>

// user-defined type-guard
function postHasAuthor(p: Post): p is PostWithRelations {
  return Boolean(p.author)
}

function printAuthorUsername(post: PostWithRelations): void {
  console.log(post.author.username)
}

export async function main() : Promise<void> {
  const post = await Post.findOneOrFail('1234', { relations: ['author'] })

  if (!postHasAuthor(post)) {
    return
  }

  printAuthorUsername(post)
}

Improving developer experience (DX)

Now, looking at the code so far, it is easy to think these type-checs will create a lot of boilerplate code. So, let’s try to make things generic and see if we can create something more ergonomic to use.

Consider this type-guard function:

type RequiredPick<T, K extends keyof T> = { [P in K]: NonNullable<T[P]> }

function has<T, K extends keyof T>(object: T, key: K): object is T & RequiredPick<T, K> {
  return Boolean(object[key])
}

// == usage ==

has(post, 'author') 
// post will be Post & RequiredPick<Post, 'author'> if true

// other examples
has(post, 'audio_transcription') 
has(user, 'profile_photo') 

Let’s break the type-guard down:

Going one step further, we can modify the type-guard to accept and validate more them one key from a given object:

function has<T, K extends (keyof T)[]>(o: T, ...keys: K) : o is T & RequiredPick<T, K[number]> {
  for (const key of keys) {
    if (!o[key]) return false
  }
  return true
}

// == usage ==

has(post, 'author')
has(post, 'author', 'content')


const relations = ['author', 'content'] as const
has(post, ...relations)


has(post, ...['author', 'content'] as const)

By adding the spread operator to the parameters, we can now pass multiple keys as parameters, and the type-guard will validate all of them and return whether the object has all of them defined or not, also informing Typescript of the correct resulting type in the process.

Finally, we can go the extra mile and really improve the DX by allowing developers to pass one key as param, many keys as params, or a list of keys as param. To achieve this, some type of gymnastics is needed, but the end result is pretty good:

function has<T extends Record<string, unknown>, K extends keyof T>(obj: T, key: K) : obj is T & RequiredPick<T, K>
function has<T extends Record<string, unknown>, K extends (keyof T)[]>(obj: T, keys: K) : obj is T & RequiredPick<T, K[number]>
function has<T extends Record<string, unknown>, K extends (keyof T)[]>(obj: T, ...keys: K) : obj is T & RequiredPick<T, K[number]>

function has(obj: Record<string, unknown>, ...keys: string[]): obj is Record<string, unknown> {
  const flatKeys = Array.isArray(keys[0]) ? keys[0] : keys
  return flatKeys.every((k) => k in obj && obj[k] !== undefined && obj[k] !== null)
}

// == usage ==

// Check if a post has an author
const post = await Post.findOneOrFail('1234', { relations: ['author'] })
if (has(post, 'author')) {
  // post is Post & RequiredPick<Post, "author"> inside the IF block
  printAuthorUsername(post)
}

// Check if a post has an author and content
const post2 = await Post.findOneOrFail('5678', { relations: ['author'] })
if (has(post2, ['author', 'content'])) {
  // post2 is Post & RequiredPick<Post, "author" | "content"> inside the IF block
  console.log(post2.author.username, post2.content)
}
if (has(post2, 'author', 'content')) {
  // post2 is Post & RequiredPick<Post, "author" | "content"> inside the IF block
  console.log(post2.author.username, post2.content)
}

// Check if a post has an author and content using an array of keys
const relations : (keyof Post)[] = ['author', 'content']
const post3 = await Post.findOneOrFail('91011', { relations })
if (has(post3, relations)) {
  // post3 is Post & RequiredPick<Post, "author" | "content"> inside the IF block
  console.log(post3.author.username, post3.content)
}

Conclusion

In this article, we explored a solution to address the issues of missing relations in TypeScript projects using TypeORM. Through the use of custom utility types, type-guards, and a flexible has function, we managed to provide a way to check for the challenges related to missing relations in TypeORM, while still improving developer experience by reducing boilerplate code expressed in the form of repeated null-checks.

The solution leverages TypeScript's type system to enforce proper handling of nullable relations, while also offering a more ergonomic and versatile approach to managing required relations. By employing this solution, developers can catch potential issues at compile time, leading to faster error detection and more maintainable, error-resistant applications.

In summary, the techniques and concepts showcased in this article provide an effective way to improve the overall quality and reliability of projects using TypeORM and TypeScript, helping developers confidently build applications with correct typing for relations and an ergonomic way to validate them both in compile time and run time.