Object Narrowing in Typescript with Graphile Worker
Tags: typescript, learning
Published: 2024-01-31 01:53:57

I have been using [graphile worker](https://worker.graphile.org) for some time for my [Matter of Memory](https://matterofmemory.com) 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](https://github.com/taskforcesh/bullmq) 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](https://worker.graphile.org/docs/installation) that the graphile-worker team has done.)

Let's say I have 2 background jobs:
- Each have different task names
- Each have different payloads

```typescript
// 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
}
```

Setup the runner to register these tasks:
```typescript
// 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;
```

At this point, we go somewhere else on the server and attempt to add a job for `convertFile`.
```javascript
// 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?
});
```

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:
```typescript
// backgroundjobs/index.ts

export async function addJob(
	jobName: keyof BackgroundJobProps,
	options: BackgroundJobProps[keyof BackgroundJobProps],
	spec: TaskSpec = {}
)
```

With Object Narrowing:
```typescript
// backgroundjobs/index.ts

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

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:
```typescript
// 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
});
```

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