2

I am working on an event-driven application framework that leverages a middleware pattern to process events dispatched from my service to third party applications. My service models many different kinds of events that third party applications may wish to register for and receive.

As event payloads are received by the framework, they are 'augmented' with middleware-specific additions like a next() function and user-definable context - relatively standard issue middleware stuff. Some events, however, have further specific augmentations available only on particular events. For example, message events have a message property, foo events may have a foo property, and so on - though the event type/name and the event-specific augmentation(s) may not map perfectly one-to-one and/or they may have multiple augmentations.

The way these event-specific augmentations are currently typed in the app framework is problematic and causes errors often; as a result, the codebase uses type assertions (as) a lot - probably a bad sign. Admittedly I am not a TypeScript expert and the framework code predates my involvement; I just thought 'this is how TS projects are built I guess.' After a couple years and learning more about TypeScript, I think now that there must be a better way!

Here is a TypeScript playground for the example, that I also copied below. The wrapMiddleware method errors with:

Argument of type 'MiddlewareArgs<"message">' is not assignable to parameter of type 'MiddlewareArgs<string>'.
  Types of property 'message' are incompatible.
    Type 'MsgEvent' is not assignable to type 'undefined'.
import { expectAssignable } from 'tsd';

// A couple of events, and a union of all events (there are more in actuality)
interface MsgEvent {
  type: 'message';
  text: string;
  channel: string;
  user: string;
}
interface JoinEvent {
  type: 'join';
  channel: string;
  user: string;
}
type AllEvents = MsgEvent | JoinEvent;

// Utility types to 'extract' event payloads out based on the `type` property
type KnownEventFromType<T extends string> = Extract<AllEvents, { type: T }>;
type EventFromType<T extends string> = KnownEventFromType<T> extends never ? { type: T } : KnownEventFromType<T>;

// A contrived example of arguments passed to middleware
interface MiddlewareArgs<EventType extends string = string> {
  event: EventFromType<EventType>;
  message: EventType extends 'message' ? this['event'] : undefined; // <-- problematic; there must be a better way, right?
}

// Augmenting events with additional middleware things
type AllMiddlewareArgs = {
  next: () => Promise<void>;
}
function wrapMiddleware<Args extends MiddlewareArgs>(
  args: Args,
): Args & AllMiddlewareArgs {
  return {
    ...args,
    next: async () => {},
  }
}

// And now for the actual example:
const messageEvt: MsgEvent = {
  type: 'message',
  channel: 'random',
  user: 'me',
  text: 'hello world',
}
const messageEvtArgs: MiddlewareArgs<'message'> = {
  event: messageEvt,
  message: messageEvt,
}
const joinEvt: JoinEvent = {
  type: 'join',
  channel: 'random',
  user: 'me'
}
const joinEvtArgs: MiddlewareArgs<'join'> = {
  event: joinEvt,
  message: undefined, // <-- bonus points if we can get rid of having to set an undefined message!
}

// Some test cases
expectAssignable<AllMiddlewareArgs>(wrapMiddleware(messageEvtArgs));
expectAssignable<MiddlewareArgs<'message'>>(wrapMiddleware(messageEvtArgs));
expectAssignable<AllMiddlewareArgs>(wrapMiddleware(joinEvtArgs));
expectAssignable<MiddlewareArgs<'join'>>(wrapMiddleware(joinEvtArgs));
// Wrapping random untyped events should fallback
expectAssignable<AllMiddlewareArgs>(wrapMiddleware({ event: { type: 'garbage' }}));
expectAssignable<MiddlewareArgs<'garbage'>>(wrapMiddleware({ event: { type: 'garbage' }}));

I understand why I get the error I do: the wrapMiddleware method accepts the wider, non-message-specific MiddlewareArgs type, which sets the generic parameter to be string, so the interface's message property could be either a message object or undefined as per the conditional type for the message property. My question is: what's a better way to structure this approach so that it scales to more events, with different event-specific middleware argument shapes?

7
  • Your playground link is corrupted. Commented Sep 20, 2024 at 21:27
  • (see prev comment). Please edit to show more example usages with other things passed into wrapMiddleware(), especially showing what's allowed and disallowed, and what happens if you allow something with a random type that isn't in your list of events. Right now it's easy enough to make wrapMiddleware(messageEvtArgs) work, but it might break things you're not showing. A range of test cases would be very helpful. I'm also confused as to why you'd want message to either be undefined or the same exact thing as event. Seems like a complication without motivation. Commented Sep 20, 2024 at 21:35
  • Sorry about the link, I've updated it and expanded the example with a few tsd-based test cases for the two events I've included in the list. The KnownEventFromType utility helper is supposed to handle the fallback scenario of dealing with an unknown type - it should set the event property to be only { type: T } in that situation. ideally, the need to define message as undefined also can be eliminated.. somehow. Thanks for any help! Commented Sep 20, 2024 at 21:58
  • And are you showing the use cases where you have an unknown type? That is, demonstrating why it’s there with a test case? Commented Sep 20, 2024 at 22:26
  • I've now added two additional test cases for my expectation on handling untyped events (e.g. an event with type: 'foo' should set the middleware arguments' event to { type: 'foo' }). To be honest, I don't think this is used in the app framework at all, nor in practice in third party applications, so personally I don't think it needs to be maintained as a constraint. I'm much more interested in your thinking and how you would structure this general pattern, esp. in order to scale up to more events with more event-specific middleware argument customizations. Thanks a lot! Commented Sep 20, 2024 at 22:45

1 Answer 1

1

For all your tests to pass, by far the easiest approach is to allow wrapMiddleware() to accept any object input:

function wrapMiddleware<A extends object>(a: A): A & AllMiddlewareArgs {
  return {
    ...a,
    next: async () => { },
  }
}

// all okay
expectAssignable<AllMiddlewareArgs>(wrapMiddleware(messageEvtArgs));
expectAssignable<MiddlewareArgs<'message'>>(wrapMiddleware(messageEvtArgs));
expectAssignable<AllMiddlewareArgs>(wrapMiddleware(joinEvtArgs));
expectAssignable<MiddlewareArgs<'join'>>(wrapMiddleware(joinEvtArgs));
expectAssignable<AllMiddlewareArgs>(wrapMiddleware({ event: { type: 'garbage' } }));
expectAssignable<MiddlewareArgs<'garbage'>>(wrapMiddleware({ event: { type: 'garbage' } }));

That's because, on a basic level, all wrapMiddleware does is spread the input into a new object with an added next method of the write type. If you just care about such tests passing, then this is how you should do it. It's so simple.


The only reason you'd do anything else is if you want to somehow reject inputs that fail to match some out-of-band constraint:

function wrapMiddleware<A extends { event: { type: string } }>(
  a: A & MiddlewareArgs<A["event"]["type"]>
): A & AllMiddlewareArgs {
  return {
    ...a,
    next: async () => { },
  }
}

Now we'll only accept a whose type A is something with a string-valued type property in its event property, and furthermore, only something which is assignable to MiddlewareArgs<A["event"]["type"]>. It's a validation to make sure that known types have the properties you care about. You can mostly leave your definitions alone, but the important bit that allows message to be missing and not undefined unless type is "message" is to change your MiddlewareArgs to a version that puts the conditional type at a higher level, so that either { message: MsgEvent } is required or not:

type MiddlewareArgs<K extends string = string> = {
  event: EventFromType<K>;
} & (K extends "message" ? { message: MsgEvent } : unknown)

Note that the unknown type is the identity element for intersections, so {event: EventFromType<K>} & unknown is just {event: EventFromType<K>}.

Your existing tests still pass, but now things can fail, such as:

wrapMiddleware({ event: { type: "message", channel: "", text: "", user: "" } }); // error!
//             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Property 'message' is missing
wrapMiddleware({ event: { type: "join", user: "" } }); // error!
//               ~~~~~
// Property 'channel' is missing

Playground link to code

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

Comments

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.