Skip to content

This blog post tackles a small but important detail in the Knex.js QueryBuilder interface. Misused, it can cause race conditions and unexpected behavior within your application using your DAOs.

The issue

Let's try to understand the issue with help of the following example DAO:

export const fooDAO = {
delete: async (pg: Knex, itemId: string): Promise<number> => {
return await pg.table<Foo>('foo').del().where('id', itemId);
},

insert: async (pg: Knex, item: FooUpdate): Promise<Foo[]> => {
return pg.table<Foo>('foo').insert(item).returning('*');
}
};

Let's walk through the differences between both functions. The first function delete returns a Promise object, and the query is executed against the database. The second function insert returns a QueryBuilder object instead, even though, the typing states, it returns a Promise<Foo[]>. In this case, the query is not executed directly. We will take a closer look later, why this is the case.

You might argue, why is the compiler not complaining as the typing is obviously wrong, and why does it have different behavior?

The typing

The compiler is not complaining because of the Promise/A+ definition. This definition states, that an Object is a Promise as long as a then(onFulfilled, onRejected) method is implemented. Checking the typing or documentation of the QueryBuilder interface, you can see that it defines a then method. Therefore, it is treated as Promise during type checks.

This misleading typing can result in race conditions or unexpected state within your application. If you mix this up within your DAOs, your queries will not be executed in the order you might think. For some functions, the query is executed while calling the DAO function itself and for the others it depends on usage in your application.

But why is this the case? Let's walk through the difference of both implementations together.

The different behavior

You must know that, Knex.js only executes the query to the database, by calling the then method and turns the QueryBuilder object into a Promise accordingly.

Let's take a closer look at the functions and understand, where the different behavior comes from. The first function uses await with the QueryBuilder creation, which implicitly calls the then method of the following Promise or object. Therefore, it is handy, that the QueryBuilder implements the same interface as Promise because you just can use await or then directly on the QueryBuilder and retrieve a Promise with the result or error. The second function just returns the created QueryBuilder object, which does not result in any query execution while calling the function itself.

Assume the following usage (might not be a real usage, but it helps to get the point).

  const doSomething = async () => {
//...
fooDAO.insert(client, {id: 'fooId', name: 'HelloWorld'});
// do other stuff
fooDAO.delete('fooId');
//...
}

In this example usage, the developer just calls the DAO functions to add the item, but is doing it asynchronously and does not await the result. Then something is going on and at some point the delete function is called to remove the item. Here again, no await is used nor the .then() method is called. This usage of the “correct” typed DAO (at least the compiler is not complaining) would result in a broken state. The item would never be deleted, as the then method of the returned QueryBuilder is never called.

The correct typing

Let's re-write the DAO with correct typing, which indicates the different return types for both functions correctly.

export const fooDAO = {
delete: async (pg: Knex, itemId: string): Promise<number> => {
return await pg.table<Foo>('foo').del().where('id', itemId);
},

insert: async (pg: Knex, item: FooUpdate): QueryBuilder<Foo[]> => {
return pg.table<Foo>('foo').insert(item).returning('*');
}
};

This improved typing for the insert function now indicates clearly that a QueryBuilder is returned and not a Promise. It might help to prevent issues like described above, but is not a nice interface and usage for the DAO, is it?

Let's transform the DAO to have a pleasant interface and user experience.

The better way

This section is named “The better way” on purpose because I want to avoid stating it is the right and only way.

The small but important change is, that we add the await within the insert function to execute the query on function call and return a Promise instead of the QueryBuilder as in the above example.

export const fooDAO = {
delete: async (pg: Knex, itemId: string): Promise<number> => {
return await pg.table<Foo>('foo').del().where('id', itemId);
},

insert: async (pg: Knex, item: FooUpdate): Promise<Foo[]> => {
return await pg.table<Foo>('foo').insert(item).returning('*');
}
};

This fixed DAO always ensures, that the queries are really being executed on function call and return a Promise which can be handled properly.
I would argue this is the better approach, than returning a QueryBuilder from DAO functions. Therefore, I recommend to always return a real Promises and not QueryBuilder objects.
This makes the interface much cleaner and easier to understand. In addition, this approach does not leak internal logic to the outside.

Conclusion

Typescript helps a lot in preventing you from introducing bugs, but it is not the silver bullet for everything. There might be other libraries or functions having similar typing in place, therefore always understand the libraries you use.
For Knex.js, be aware of the typing issue with the QueryBuilder and Promise object, to prevent unexpected behavior. Nevertheless, it is the better way to implement DAOs in a way encapsulating the queries and only return data within a Promise.

Image of the author

Published by Nikolas Rist

Visit author page