0

I am using typescript and I defined the following interface:

export interface BaseProtocol {
    [key: string]: (payload: unknown) => Promise<unknown>;
}

Now I'd like to define a protocol:

import { BaseProtocol } from './BaseProtocol';

export interface AppProtocol extends BaseProtocol {
    action(payload: string): Promise<string>;
}

Unfortunately I get the following error:

Property 'action' of type '(payload: string) => Promise<string>' is not assignable to string index type '(payload: unknown) => Promise<unknown>'.ts(2411)

I am not willing to use any because it hurt the type safety of the Protocol usage. Any idea how I can settle this issue?

5
  • type AppProtocol = { action(payload: string): Promise<string>; } & BaseProtocol; Commented Mar 19, 2020 at 23:54
  • What is the difference? Commented Mar 19, 2020 at 23:58
  • That's a TS type system quirks, not sure if it's documented anywhere, but that's how it should be done. Commented Mar 20, 2020 at 0:00
  • But than this gives me an error: const a:Protocol = { action(payload: string){ return new Promise<string>((resolve)=>resolve() ); };: Type 'unknown' is not assignable to type 'string'. Commented Mar 20, 2020 at 0:02
  • Oh, that's a surprise then, it should work. Let's see.. Commented Mar 20, 2020 at 0:07

1 Answer 1

3

The error is a valid one; if you have interfaces A and B extends A, then that if I want an A it's okay for you to hand me a B. But with your definitions, that breaks:

function callBaseProtocol(bp: BaseProtocol) {
    return bp.action(12345);
}

function callAppProtocol(ap: AppProtocol) {
    callBaseProtocol(ap); // oops!
}

The function callBaseProtocol() is not in error. The definition of BaseProtocol says that every string-valued key is a function which takes a value of type unknown and returns a Promise<unknown>. Specifically, the action property of a BaseProtocol must be a function that accepts a value of type unknown. You can therefore pass it a number.

The function callAppProtocol() is also not in error. Here we are calling callBaseProtocol() and passing it an AppProtocol. That is supposed to be acceptable because AppProtocol extends BaseProtocol.

But if an AppProtocol has an action property that expects a string and calls some string-specific method on it like x => x.toUpperCase(), this will explode. The error is that AppProtocol does not, in fact, properly extend BaseProtocol.


This possibly surprising phenomenon is known as parameter contravariance, where if X is a subtype of Y, then the function (y: Y)=>any is a subtype of (x: X)=>any, and not vice versa. The direction of subtyping goes the other way for function parameters, and that "goes the other way" is what "contravariance" means.

This behavior was introduced in TypeScript via the --strictFunctionTypes compiler option, and is a type safety improvement.


So how can you deal with this? Assuming you want increased type safety and not reduced type safety (you mention not wanting to use any), then perhaps your BaseProtocol isn't really properly defined. Maybe instead of having a string index signature and passing around unknowns, it should be a generic interface that represents a more complex relationship between input and output. Perhaps something like this:

export type BaseProtocol<T> = {
    [K in keyof T]: (payload: T[K]) => Promise<T[K]>;
}

This is saying that a BaseProtocol depends on another type T, and that a BaseProtocol<T> has a method for every property of T, which takes a parameter of the type of that property value, and returns a Promise of that type.

And then AppProtocol can be simply defined as:

export interface AppProtocol extends BaseProtocol<{ action: string }> {
}

This solves the problem from before, since the compiler will not allow you to call a BaseProtocol<T>'s action() method on a number argument unless it knows that T has an action property of type number:

function callBaseProtocol<T>(bp: BaseProtocol<T>) {
    return bp.action(12345); // error! might not have an action
}

There's also a hybrid approach that doesn't assume the return value of each function is a promise of the same type as its input:

export type BaseProtocol<T> = {
    [K in keyof T]: (payload: T[K]) => unknown;
}

export interface AppProtocol extends BaseProtocol<{ action: string }> {
    action(x: string): Promise<string>;
}

Okay, hope that helps; good luck!

Playground link to code

Sign up to request clarification or add additional context in comments.

1 Comment

Thank you very much fo the explanation. Actually, the reason I need BaseProtocol at all, is that I have a sendMssage<Protocol extends BaseProtocol>(name: keyof Protocol, payload: Parameters<keyof Protocol>[0]) method that uses Parameters<> and ReturnType<>. And BTW, return type is different than the payload which makes everything complex.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.