Search code examples
typescript

TypeScript is not throwing a type error when function return type doesn't match generic type


I'm having trouble with TypeScript not throwing type errors in my code. I have a function registerIpcChannel that registers an IPC channel with a specific handler. The handler should match the types defined in the IpcChannel interface. This works for the most part, however, when I intentionally return undefined or null from the handler, TypeScript does not throw a type error as expected. Here's a simplified version of my code:

/**
 * Interface that describes an IPC channel
 * @template P - Type of the parameters for the handler
 * @template R - Type of the return value for the handler
 */
interface IpcChannel<P extends any[], R> {
  name: string;
}

/**
 * Function that takes in a IpcChannel interface, handler function, registers it
 */
const registerIpcChannel = <P extends any[], R>(
  ipcChannel: IpcChannel<P, R>,
  handler: (...params: P) => R
): void => {
  // Do things...
};

/**
 * Create definition of an IPC channel
 * Accepts a single parameter that is a string, returns a string
 */
const testIpcChannel: IpcChannel<[string], string> = {
  name: '/foo/bar',
};

//
// Testing the register function
//

/**
 * Register the IPC channel
 * Sanity check passes, no type error is thrown.
 */
registerIpcChannel(testIpcChannel, (arg) => {
  return 'This returns a string: ' + arg;
});

/**
 * Test with returning the wrong type
 * Sanity check passes, a type error is thrown because a number is returned.
 */
registerIpcChannel(testIpcChannel, (arg) => {
  return 1;
});

/**
 * Test with returning undefined
 * Sanity check FAILS, this should throw a type error
 */
registerIpcChannel(testIpcChannel, (arg) => {
  return undefined;
});

The last registerIpcChannel call that returns undefined should be throwing a type error, however it is not.

I found that if I constrain the return type of handler to NonNullable, that will make a type error be thrown. However that then breaks situations where the handler function returns void.

const registerIpcChannel = <P extends any[], R>(
  ipcChannel: IpcChannel<P, R>,
  handler: (...params: P) => NonNullable<R> // + Added `NonNullable`
): void => {
  // Do things...
};

/**
 * Void test
 * Fails, this now throws an error after adding `NonNullable`
 */
const voidIpcChannel: IpcChannel<[string], void> = {
  name: '/foo/bar',
};

registerIpcChannel(voidIpcChannel, (arg) => {
  return;
});

Interestingly, it seems that if I refactor the way things are structured by moving the handler definition to the IpcChannel interface then things behave how I would expect.

//
// Handler on the `IpcChannel` interface
//
interface IpcChannel2<P extends any[], R> {
  name: string;
  handler: (...args: P) => R;
}

/**
 * Sanity check passes, a type error is thrown.
 */
const test: IpcChannel2<[string], string> = {
  name: '/foo/bar',
  handler: (arg) => {
    return undefined;
  },
};

However, due to limitations I cannot do this. I need to have the handler function defined separately from the IpcChannel interface.

StackBlitz


Solution

  • The problem you're having seems to be that if you define registerIpcChannel like

    const registerIpcChannel = <P extends any[], R>(
      ipcChannel: IpcChannel<P, R>,
      handler: (...params: P) => R
    ): void => {
      // Do things...
    };
    

    with registerIpcChannel(channel, handler), TypeScript will infer R from handler as well as from ipcChannel. So if ipcChannel is IpcChannel<P, X> and handler is (...params: P)=>undefined then TypeScript infers R as X | undefined.

    Since your IpcChannel<P, R> is structurally independent of P and R (this is a problem you should fix but it's out of scope for the question as asked) then IpcChannel<P, X | undefined> matches IpcChannel<P, X> and you get no error when you want one.

    It seems therefore that you really don't want to allow TypeScript to use handler to infer R, and instead want to use ipcChannel alone (keeping in mind that this doesn't always work). If so, you could use the NoInfer<T> utility type to block inference there:

    const registerIpcChannel = <P extends any[], R>(
      ipcChannel: IpcChannel<P, R>,
      handler: (...params: P) => NoInfer<R>
    ): void => {
      // Do things...
    };
    

    And now your tests behave as expected:

    const testIpcChannel: IpcChannel<[string], string> = {
      name: '/foo/bar',
    };
    
    registerIpcChannel(testIpcChannel, (arg) => {
      return 'This returns a string: ' + arg; // okay
    });
    
    registerIpcChannel(testIpcChannel, (arg) => 1); // err
    
    registerIpcChannel(testIpcChannel, (arg) => undefined); // err
    
    const voidIpcChannel: IpcChannel<[string], void> = {
      name: '/foo/bar',
    };
    
    registerIpcChannel(voidIpcChannel, (arg) => {
      return;
    }); // okay
    

    Playground link to code