Home > Software design >  How to type an asynchronous potentially process-exiting function in typescript?
How to type an asynchronous potentially process-exiting function in typescript?

Time:06-05

How do I type an asynchronous function that sometimes exits the process?
I want to be able to use it like this:

function useResult(result:Result): void {
    // ...
}

const result: Result | undefined = await getResult();
if (result === undefined) reportError({ fatal: true });
useResult(result) // I want the type of result to be `Result` here, not `Result | undefined`

const result: Result | undefined = await getResult();
if (result === undefined) reportError({ fatal: false });
useResult(result) // I want the type of result to be `Result | undefined` here

Here's the function in question:

import { exit } from "node:process";

interface ReportableError {
    fatal?: true | false | undefined;
    // ...
}

export async function reportError<E extends ReportableError>(
    e: E,
) { // <-- How do I type the return of this function?

    // Log the error, do some async stuff...

    if (e.fatal) {
        // Stop running
        exit();
    }
    // Continue running
}

CodePudding user response:

I think the only way this will work is if you make reportError() an overloaded function with separate call signatures for the "fatal" and "non-fatal" varieties of input.


Essentially you want reportError({fatal: true}) to return the never type because the function will never return. When you call a function whose call signature's return type is just never, the compiler can use control flow analysis to narrow the types of variables based on reachability of code. This was implemented in microsoft/TypeScript#32695, and only triggers if the return type is explicitly annotated as just never.

Since you want

if (result === undefined) reportError({ fatal: true });

to narrow result from Result | undefined to Result, that means reportError() must return a never. So one call signature of reportError should look like

function reportError(e: { fatal: true }): never;

Note that we cannot write async function reportError(e: {fatal: true}): Promise<never> because Promise<never> does not affect reachability the way we want... this is marked as a bug at microsoft/TypeScript#34955.


On the other hand, when you call reportError() where fatal is not true, you want this to just be an asynchronous function which returns void... well, Promise<void>. Like this:

async function reportError(e: { fatal?: false | undefined }): Promise<void>;

And we have to separate out the fatal and non-fatal cases into their own call signatures in order for reachability analysis to work how we want. A single generic call signature that returns a conditional type like <E>(e: ReportableError) => E extends {fatal: true} ? never : Promise<void> is conceptually the same, but is not treated like an assertion by the compiler.


Okay, so we have two call signatures and we need an implementation. The full function looks like

function reportError(e: { fatal: true }): never;
async function reportError(e: { fatal?: false | undefined }): Promise<void>;
async function reportError(e: ReportableError) {
  if (e.fatal) {
    throw new Error("EXITING PROCESS OR SOMETHING");
  }
}

And let's see if it works how we want:

const result: Result | undefined = await getResult();
if (result === undefined) reportError({ fatal: true });
useResult(result) // okay

Great, result is narrowed. And if we change the fatality level:

const result: Result | undefined = await getResult();
if (result === undefined) reportError({ fatal: false });
useResult(result) // error! undefined is not assignable to Result

Now result is not narrowed, and the compiler dislikes useResult(result) because useResult() does not accept undefined.

Playground link to code

CodePudding user response:

export async function reportError<E extends ReportableError>(
    e: E,
): Promise<E["fatal"] extends true ? Result : Result | undefined> {
  // ...
}

See conditional types

  • Related