9 min read

Node-style callbacks in Typescript

Node-style callbacks in Typescript

There are many different options for defining node-style callbacks in Typescript. Some are better than others. Here are some variations and potential solutions I’ve encountered along the way.

Required Result

A very common option, which I'll call "required result", follows a similar pattern to how they are defined in the @types/node package.

// "Required result" style callback
type Callback<T, E = Error> = (err: E | null, result: T) => void;

// A function that expects a primitive value that
// isn't `null` or `undefined`.
function fnLogPrimitive<T extends string | number | boolean>(result: T) {
  console.log(result);
}

// Function that accepts a callback function
function fnWithCb(isSuccess: boolean, cb: Callback<number>): void {
  if (isSuccess) {
    cb(null, 1);
  } else {
    // Apply a hacky workaround for Typescript to ignore that we are not passing a result.
    // Choose your personal favourite here.
    cb(new Error("I did not succeed."), undefined as unknown as number);
  }
}

fnWithCb(true, (err, result) => {
  // What if we forget this error check?
  // Typescript will still allow
  // the call to fnLogPrimitive if
  // you comment it out
  if (err) {
    return;
  }
  fnLogPrimitive(result);
});
TS Playground - An online editor for exploring TypeScript and JavaScript
The Playground lets you write TypeScript or JavaScript online in a safe and sharable way.

Try out the above code in the Typescript Playground

The benefits are:

  • It is relatively frictionless to implement if you are willing to introduce a sacrificial workaround.
  • It is frictionless to consume.
  • It seemingly follows the recommended best practices.

The issues are:

  • There is nothing preventing the consumer from attempting to use the callback result if they haven’t correctly checked for the presence of an error first.
  • It usually requires some sort of undesirable workaround by the implementor when the result is unavailable, e.g.
    • It is casted away as in the example above.
    • A @ts-ignore or @ts-expect-error comment is added to suppress the compiler error.
    • The underlying implementation is written in an non-type safe language so no workaround is necessary, e.g. Javascript.

Optional Result

You may recognise the issue with the result being optional and/or not feel comfortable with the options to workaround it. The next logical option, I'll call "optional result" attempts to account for that.

// "Optional result" style callback
type Callback<T, E = Error> = (err: E | null, result?: T) => void;

// A function that expects a primitive value that
// isn't `null` or `undefined`.
function fnLogPrimitive<T extends string | number | boolean>(result: T) {
  console.log(result);
}

// Function that accepts a callback function
function fnWithCb(isSuccess: boolean, cb: Callback<number>): void {
  if (isSuccess) {
    cb(null, 1);
  } else {
    cb(new Error("I did not succeed."));
  }
}

fnWithCb(true, (err, result) => {
  // Even with the below error check,
  // Typescript will not allow
  // the call to fnLogPrimitive
  // without asserting in some
  // way that result exists.
  if (err) {
    return;
  }

  // This does not compile unless we use
  // the non-null assertion operator
  fnLogPrimitive(result!);
});
TS Playground - An online editor for exploring TypeScript and JavaScript
The Playground lets you write TypeScript or JavaScript online in a safe and sharable way.

Try out the above code in the Typescript Playground

The benefits are:

  • It is frictionless to implement and doesn’t require a horrible workaround.

The issues are:

  • There is nothing preventing the consumer from attempting to use the callback result if they haven’t correctly checked for the presence of an error first.
  • It passes friction onto the consumer who now needs to assert the presence of the result. This can be seen as an advantage because it may trigger the consumer to remember to check for the error first.

Union Types

It is clear that there are two very different usages of callback function, i.e. when there is an error and when there is a result. You cannot have both at the same time so they are mutually exclusive. Both solutions above don’t really account for that very well. A common though process to account for this may lead one to attempt using a union type for this.

// "Union type" style callback
type Callback<T, E = Error> = (
  err: E,
) => void | ((err: null, result: T) => void);

// A function that expects a primitive value that
// isn't `null` or `undefined`.
function fnLogPrimitive<T extends string | number | boolean>(result: T) {
  console.log(result);
}

// Function that accepts a callback function
function fnWithCb(isSuccess: boolean, cb: Callback<number>): void {
  if (isSuccess) {
    // Fails to compile with:
    // Expected 1 arguments, but got 2
    cb(null, 1);
  } else {
    cb(new Error("I did not succeed."));
  }
  cb(new Error());
}

// We have to explicitly type the result as
// optional for this to compile
fnWithCb(true, (err, result?: number) => {
  // What if we forget this check? Typescript will still allow
  // the call to fnLogPrimitive.
  if (err) {
    return;
  }

  // This still does not compile unless we use
  // the non-null assertion operator
  fnLogPrimitive(result!);
});
TS Playground - An online editor for exploring TypeScript and JavaScript
The Playground lets you write TypeScript or JavaScript online in a safe and sharable way.

Try out the above code in the Typescript Playground

The benefits are:

  • We tried to to do something better and learned that it doesn’t really work.

The issues are:

  • There is still nothing preventing the consumer from attempting to use the callback result if they haven’t correctly checked for the presence of an error first.
  • It passes friction onto the consumer who now needs to assert the presence of the result.
  • We cannot rely on type inference for the result in our callback function.
  • It doesn’t actually compile because, the compiler cannot narrow the union. There may be some way to make it work but I choose not to expend further brain power on it since we could simply use one of the above simpler options.

Interfaces

A better alternative to the union type approach is to define to define it as an interface.

// "Interface" style callback
interface Callback<T, E = Error> {
  (err: E): void;
  (err: null, result: T): void;
}

// A function that expects a primitive value that
// isn't `null` or `undefined`.
function fnLogPrimitive<T extends string | number | boolean>(result: T) {
  console.log(result);
}

// Function that accepts a callback function
function fnWithCb(isSuccess: boolean, cb: Callback<number>): void {
  if (isSuccess) {
    // This compiles, unlike the union type.
    cb(null, 1);
  } else {
    cb(new Error("I did not succeed."));
  }

  cb(new Error());
}

// We have to explicitly type the arguments of the
// callback for this to compile
fnWithCb(true, (err, result?: number) => {
  // What if we forget this check? Typescript will still allow
  // the call to fnLogPrimitive.
  if (err) {
    return;
  }

  // This still does not compile unless we use
  // the non-null assertion operator
  fnLogPrimitive(result!);
});
TS Playground - An online editor for exploring TypeScript and JavaScript
The Playground lets you write TypeScript or JavaScript online in a safe and sharable way.

Try out the above code in the Typescript Playground

The benefits are:

  • At least it compiles.

The issues are:

  • There is still nothing preventing the consumer from attempting to use the callback result if they haven’t correctly checked for the presence of an error first.
  • It passes friction onto the consumer who now needs to assert the presence of the result.
  • We cannot rely on type inference for the result in our callback function.

Guard Function

The complications for the options above lead some consumers to take unto themselves to improve the situation and add a simple helper guard function. It would be great if more implementers provided this for them.

// "Optional result" style callback
type Callback<T, E = Error> = (err: E | null, result?: T) => void;

// A function that expects a primitive value that
// isn't `null` or `undefined`.
function fnLogPrimitive<T extends string | number | boolean>(result: T) {
  console.log(result);
}

// Using this guard function ensures that
// code can assume `result` exists if it returns true.
// However, the calling code needs to handle the case
// when it returns false
function isOk<T, E extends Error>(
  err: E | null | undefined,
  result?: T,
): result is T {
  return err == null;
}

// Function that accepts a callback function
function fnWithCb(isSuccess: boolean, cb: Callback<number>): void {
  if (isSuccess) {
    cb(null, 1);
  } else {
    cb(new Error("I did not succeed."));
  }
}

fnWithCb(true, (err, result) => {
  if (!isOk(err, result)) {
    return;
  }

  // This only compiles if we reach here after
  // `isOk` returns true.
  fnLogPrimitive(result);
});
TS Playground - An online editor for exploring TypeScript and JavaScript
The Playground lets you write TypeScript or JavaScript online in a safe and sharable way.

Try out the above code in the Typescript Playground

he benefits are:

  • It works with both the optional result option or required result option but works best with the optional result option to ensure result is only usable when there is no error.
  • It is relatively frictionless.

The issues are:

  • A little more friction for either the implementer or consumer to provide/use the helper guard function.
  • It doesn’t prevent a consumer from still using the non-null assertion operator to bypass the type-safety.

Assertion Function

There is an alternative to using a guard function and that is to use an assertion function.

// "Optional result" style callback

type Callback<T, E = Error> = (err: E | null, result?: T) => void;

// A function that expects a primitive value that
// isn't `null` or `undefined`.
function fnLogPrimitive<T extends string | number | boolean>(result: T) {
  console.log(result);
}

// Using this assert function ensures that
// code can assume `result` exists after having called it.
// However, the calling code needs to support catching the
// exception somewhere in its call-stack
function assertIsOk<T, E extends Error>(
  err: E | null | undefined,
  result?: T,
): asserts result is T {
  if (err != null) {
    throw err;
  }
}

// Function that accepts a callback function
function fnWithCb(isSuccess: boolean, cb: Callback<number>): void {
  if (isSuccess) {
    cb(null, 1);
  } else {
    cb(new Error("I did not succeed."));
  }
}

fnWithCb(true, (err, result) => {
  // Using an assertion function, i.e. I can throw an
  // error that needs to be caught somewhere.
  try {
    assertIsOk(err, result);
    // We only reach here if there is no error
    fnLogPrimitive(result);
  } catch (err) {
    console.error(err);
  }
});

The assertion function isn’t really better or worse than using the guard function option but it may suite different usage patterns or coding styles better, e.g. some may debate whether using exceptions for non-exceptional error control flow is ideal.

TS Playground - An online editor for exploring TypeScript and JavaScript
The Playground lets you write TypeScript or JavaScript online in a safe and sharable way.

Try out the above code in the Typescript Playground

Control Flow Analysis for Dependent Parameters

Since Typescript 4.6 there is another option available called, Control Flow Analysis for Dependent Parameters, which I haven't seen used in the wild very often. It provides some nice benefits without the same drawbacks as the other options.

// Dependent Parameter style arguments.
type CallbackWithResultArgs<T, E = Error> =
  | [err: null, result: T]
  | [err: E, result: undefined];

// A function that expects a primitive value that
// isn't `null` or `undefined`.
function fnLogResult<T extends string | number | boolean>(result: T) {
  console.log(result);
}

// Function that accepts a callback function
function fnWithCb(
  isSuccess: boolean,
  cb: (...args: CallbackWithResultArgs<number>) => void,
): void {
  if (isSuccess) {
    cb(null, 1);
  } else {
    // We are still forced to passed in `undefined`
    // for the result.
    cb(new Error("I did not succeed."), undefined);
  }
}

fnWithCb(true, (err, result) => {
  // Without this error check in place,
  // the call to fnLogResult below will
  // not compile.
  if (err) {
    return;
  }

  // This line only compiles with the above
  // check for `err` in place.
  fnLogResult(result);
});
TS Playground - An online editor for exploring TypeScript and JavaScript
The Playground lets you write TypeScript or JavaScript online in a safe and sharable way.

Try out the above code in the Typescript Playground

The benefits are:

  • It is completely frictionless for the consumer.
  • It is completely type safe for both the implementer and consumer.

The issues are:

  • Dependent parameters is a lesser known feature so not all implementers will be aware of it.
  • It is only supported in Typescript 4.6 and above.
  • The syntax is a little awkward when you are not used to it.

Conclusion

There are several options to choose from and there is no single best choice.

Ultimately, the one you choose to use is dependent on your preferred coding style and/or the constraints of your system and environment.

However, it is useful to be aware of what they are and understand the implications of choosing each one form the perspective of both the producer and more importantly, the consumer.