DEV Community

Cover image for Object Narrowing in Typescript with Graphile Worker
Hector Leiva
Hector Leiva

Posted on

Object Narrowing in Typescript with Graphile Worker

I have been using graphile worker for some time for my Matter of Memory project to run async tasks on my Node.js server.

Graphile worker has been great for me because it's a library that works with Postgres that allows me to queue jobs and execute them on the server without adding too many additional layers of complexity for being able to accomplish async tasks. (I'm aware of how popular bull is, but I don't want to add another data-store only for async tasks)

One of the annoying roadblocks I encountered in my implementation of graphile-worker in Typescript was in making sure that whenever I added a task, that the payload I was giving it matched the task name.

Here's an example of the issue I was facing (please note: I'm omitting a lot of set-up code for getting graphile-worker actually working; for that please look at the excellent docs that the graphile-worker team has done.)

Let's say I have 2 background jobs:

  • Each have different task names
  • Each have different payloads
// backgroundjobs/tasks/files.ts (terrible name I know)

import { Task } from "graphile-worker";

export interface ConvertFilePayload {
    fileId: string;
    fileType: string;
}

export interface RecordFileMetaDataPayload {
    metaId: string;
}

export const convertFile: Task = async(payload: ConvertFilePayload) => {
    // ...task does work here
}

export const recordMetaDataOfFile: Task = async(payload: RecordFileMetaDataPayload) => {
    // ...task does work here
}
Enter fullscreen mode Exit fullscreen mode

Setup the runner to register these tasks:

// backgroundjobs/index.ts

import { Runner, run, TaskSpec } from "graphile-worker";
import { convertFile, recordMetaDataOfFile } from "../backgroundjobs/tasks/file";
import type { ConvertFilePayload, RecordFileMetaDataPayload } from "../backgroundjobs/tasks/file";

let runner: Runner | undefined;

interface BackgroundJobProps {
    convertFile: ConvertFilePayload;
    recordMetaDataOfFile: RecordFileMetaDataPayload;
}

async function main(connectionString: string) {
    // ...
    runner = await run({
        connectionSTring
        , taskList: {
            convertFile,
            recordMetaDataOfFile
        }
    });
    await runner.promise
}

export async function addJob(
    jobName: keyof BackgroundJobProps,
    options: BackgroundJobProps[keyof BackgroundJobProps],
    spec: TaskSpec = {}
) {
    if (runner) {
        return runner.addJob(jobName, options, spec);
    }
}

export default main;
Enter fullscreen mode Exit fullscreen mode

At this point, we go somewhere else on the server and attempt to add a job for convertFile.

// somewhere/else.ts

import { addJob } from '../backgroundJobs.ts'

backgroundJob.addJob("convertFile", {
    metaId: data.id // this is the wrong payload type, but TS is not erroring out?
});
Enter fullscreen mode Exit fullscreen mode

The problem here is that convertFile doesn't have a payload of metaId, but Typescript sees this as fine because metaId exists as a possible payload option since RecordFileMetaDataPayload is also included in BackgroundJobProps

convertFile recordMetaDataOfFile
{ fileId: string; fileType: string } { metaId: string }

Why is this bad? It will make us believe that the convertFile background job is going to work as expected, but we'll end up with a runtime error. The convertFile task will try and pull out fileId from the payload, but it'll be undefined and cause the job to fail.

With TS object narrowing, we can force a TS error whenever the incorrect payload is attempted against the background task we are targeting.

Returning to backgroundjobs/index.ts, we need to modify the Typescript interfaces for the addJob() function to be able to show us the error if we are using the wrong payload.

Previously:

// backgroundjobs/index.ts

export async function addJob(
    jobName: keyof BackgroundJobProps,
    options: BackgroundJobProps[keyof BackgroundJobProps],
    spec: TaskSpec = {}
)
Enter fullscreen mode Exit fullscreen mode

With Object Narrowing:

// backgroundjobs/index.ts

export async function addJob<T extends keyof BackgroundJobProps>(
    jobName: T,
    options: BackgroundJobProps[T],
    spec: TaskSpec = {}
)
Enter fullscreen mode Exit fullscreen mode

How this works:

  • <T extends keyof BackgroundJobProps> is a way to create a Typescript generic T that will represent one-of-the possible keyof values of BackgroundJobProps. This means (in this case) convertFile | recordMetaDataOfFile
  • jobName: T is meant to cast whichever string we write as our first argument when we use addJob("") and assign it to T here. We can only pick "convertFile" or "recordMetaDataOfFile" since those are the only keys in BackgroundJobProps. We are going to pick "convertFile" for the next point.
  • options: BackgroundJobProps[T] since we have cast T by explicitly declaring it in addJob("convertFile") what we've done is narrowed down all values in BackgroundJobProps to only the values that match BackgroundJobProps["convertFile"] and it returns those values for the options as the interface that must be matched.

Now if we attempt to do:

// somewhere/else.ts

import { addJob } from '../backgroundJobs.ts'

backgroundJob.addJob("convertFile", {
    metaId: data.id // this will now error out and show up as a TS Error
});
Enter fullscreen mode Exit fullscreen mode

This will now work as expected and return a TS error.


I had screwed this up myself some time ago which caused an influx of errors that were difficult to find since there were no TS errors being reported, not a fun day at work!

I hope you all find this helpful and learn from my mistakes.

-H

Top comments (0)