2

I am trying to make my hobby TS elegant and tight... but it's not going well.

Typescript experts, I've battled this for a few days, and would like your wisdom.

TL;DR; Drop the self-contained sample into VSCode and see compile errors.

I'm establishing my messaging platform, and each Message class has strictly-typed message.msg and message.satisfy members.

I'm hoping to establish those Message classes' type constraints so that the generic types of each receiving method infers their inner types and keep the strict-typing all the way down... but unknown is laughing at me.

Note:

  1. GOOD: The NameAgeMessage constructor argument is strongly-typed.
  2. BAD: The message passed to generic method doesn't infer types properly.
  3. BAD: The message passed to generic method doesn't infer types properly.
  4. BAD: The message passed to generic method doesn't infer types properly.
  5. BAD: Both received arguments are unknown.

Also:

  • If I add = any to my inner types it compiles, but I loose strict typing:
public publishForSatisfy<TMessage extends AppMessage<TMsg, TSatisfyArg>, TMsg = any, TSatisfyArg = any>(

My best attempt so far:

(Scroll down to see the points & errors in comments)

type NameAgeMsg = { id: number };
type NameAgeSatisfyArg = { name: string, age: number};

abstract class AppMessage<TMsg, TSatisfyArg> {
  abstract readonly msg: TMsg;
  satisfy!: undefined | ((msg: TMsg, satisfyArg: TSatisfyArg) => void);

  protected constructor() { }
};

class NameAgeMessage extends AppMessage<NameAgeMsg, NameAgeSatisfyArg> {
  constructor(
    public readonly msg: NameAgeMsg
  ) {
    super();
  }
}

class LoggingService {
  public log(callback: () => { message: string, args?: object[] }) {
    const logArgs = callback();
    console.log(`${logArgs.message}`, ...logArgs.args ?? []);
  }
}

function outputDebugInfoForMessagePublished<TMessage extends AppMessage<TMsg, TSatisfyArg>, TMsg, TSatisfyArg>(
  logging: LoggingService,
  message: TMessage): void {

  logging?.log(() => { return { message: `${message.constructor.name}`, args: [message] }; });
}

function messagingPublish<TMessage extends AppMessage<TMsg, TSatisfyArg>, TMsg, TSatisfyArg>(
  message: TMessage,
  logging: LoggingService
): void {

  // 4. BAD: The message passed to generic method doesn't infer types properly.
  // ts(2345) on 'message' argument: Type 'AppMessage<TMsg, TSatisfyArg>' is not assignable to type 'AppMessage<unknown, unknown>'.
  outputDebugInfoForMessagePublished(logging, message);
}

class MessagingService {
  private logging: LoggingService = new LoggingService();

  public publishForSatisfy<TMessage extends AppMessage<TMsg, TSatisfyArg>, TMsg, TSatisfyArg>(
    message: TMessage,
    satisfy: (msg: TMsg, satisfyArg: TSatisfyArg) => void
  ): void {

    message.satisfy = satisfy;

    // 3. BAD: The message passed to generic method doesn't infer types properly.
    // ts(2345) on 'message' argument: Type 'AppMessage<TMsg, TSatisfyArg>' is not assignable to type 'AppMessage<unknown, unknown>'.
    messagingPublish(message, this.logging);
  }
}

class TestClass {
  messaging: MessagingService = new MessagingService();

  testMethod(): void {
    // 1. GOOD: The NameAgeMessage constructor argument is strongly-typed.
    const message = new NameAgeMessage({ id: 123 });

    // 2. BAD: The message passed to generic method doesn't infer types properly.
    // ts(2345) on 'message' argument: Argument of type 'NameAgeMessage' is not assignable to parameter of type 'AppMessage<unknown, unknown>'.
    this.messaging.publishForSatisfy(message, (msg, satisfyArg) => {

      // 5. BAD: Both received arguments are 'unknown'.
      // msg is 'unknown'. Should be type NameAgeMsg (from NameAgeMessage)
      // satisfyArg is 'unknown'. Should be type NameAgeSatisfyArg (from NameAgeMessage)
    });
  }
}
AAron
  • 428
  • 3
  • 11
  • 1
    Does [this approach](https://tsplay.dev/mAx2BN) meet your needs? If so I could write up an answer explaining (essentially type parameters cannot be inferred from constraints on other type parameters, and you probably didn't need 3 type parameters anyway). If not, what am I missing? – jcalz May 22 '23 at 02:51
  • @jcalz, I'm a C# guy and didn't expect this, but I see how you went a layer down and avoided the deep-resolution from the top-level types... and made the whole problem more obvious easier to solve. Yes, it looks like your solution is working. I'll try grafting it into my actual code base and see what happens. Oh, I did have to rename 'M' and 'S' back to 'TMsg' and 'TSatisfyArg' to make sure I saw exactly what you did in a diff. After a few days on this, I'm hesitant about your VERY quick response, but I do think you nailed it. Write blurb... it can be the solution. Thx! – AAron May 22 '23 at 03:16
  • I will write up an answer when I get a chance. It’s close to my bedtime now so it might be in 8 or 9 hours. – jcalz May 22 '23 at 03:22

1 Answers1

0

Aside: I've replaced the type parameters TMsg with M, TSatisfyArg with S, and TMessage with A (for AppMessage) to conform to the TypeScript naming convention with which I'm most familiar (see Does Typescript (really) follow the naming convention for parameterized types (T, U, V, W) in Generics? for more information).


When you call a generic function and do not manually specify generic type arguments, the compiler will infer these type arguments. It does so by consulting the types of the function arguments, and by consulting the expected return type of the function's return value contextually, if such a context exists.

The problem with a call signature like

declare function outputDebugInfoForMessagePublished<A extends AppMessage<M, S>, M, S>(
  logging: LoggingService, message: A): void;

is that while the A type parameter can be inferred from the argument passed in for message, there are no inference sites for the compiler to infer type arguments for the type parameters M or S. These are not mentioned in either the function parameter types or its return type. So inference will definitely fail and they will fall back to unknown.

function messagingPublish<A extends AppMessage<M, S>, M, S>(
  message: A, logging: LoggingService): void {
  outputDebugInfoForMessagePublished(logging, message); // error!
  // <AppMessage<unknown, unknown>, unknown, unknown>
}

The type parameters are mentioned in the constraint A extends AppMessage<M, S>. But constraints are not used for type inference. Instead, There was a suggestion at microsoft/TypeScript#7234 to support such inference, but it was never implemented. Instead, the recommended workaround is to replace or supplement the constraint with an intersection at the usage site. In the case above that looks like:

declare function outputDebugInfoForMessagePublished<A extends AppMessage<M, S>, M, S>(
  logging: LoggingService, message: A & AppMessage<M, S>): void;

And when you call it, things suddenly start inferring properly:

function messagingPublish<A extends AppMessage<M, S>, M, S>(
  message: A & AppMessage<M, S>, logging: LoggingService): void {
  outputDebugInfoForMessagePublished(logging, message); // okay
  // <A, M, S>
}

So essentially all you need to do is replace references to A with A & AppMessage<M, S>.


But we can go further here, at least given the example code. It doesn't look like you actually do anything that cares about the specific subtype of AppMessage<M, S> that A represents. If all you care about is M and S, then we can forget about A entirely, and replace A with just AppMessage<M, S> (instead of A & AppMessage<M, S>.

That gives us:

declare function outputDebugInfoForMessagePublished<M, S>(
  logging: LoggingService, message: AppMessage<M, S>): void;
    
function messagingPublish<M, S>(
  message: AppMessage<M, S>, logging: LoggingService): void {
  outputDebugInfoForMessagePublished(logging, message); // okay
  // <M, S>
}

Which still infers properly but is significantly less complicated.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • I posted another question related to same code, instead of tacking on to this one. I'd welcome any thoughts. Thanks for the help! https://stackoverflow.com/questions/76310879/typescript-generic-method-uses-typename-and-deeper-types – AAron May 23 '23 at 02:06