Home > Software design >  How to correctly infer T for an RxJS Subject<T> from generic function with type constraint
How to correctly infer T for an RxJS Subject<T> from generic function with type constraint

Time:11-10

I have a module that provides the ability for callers to subscribe to a discrete set of events. When a caller subscribes for a given event, they provide the event name as the first argument. From that argument, I would like to be able to infer the callback signature.

The implementation uses an RxJS Subject for each supported event, and creates a subscription to it for every call to myModule.subscribe(eventType). A stripped back version of the implementation is shown below (you can also see this running on this TS Playground workspace)

import { Subject, Subscription } from "rxjs";

const EventNames = {
    a: "a",
    b: "b",
    c: "c",
} as const;

type Payloads = {
    [EventNames.a]: string;
    [EventNames.b]: number;
    [EventNames.c]: boolean;
};

type EventTypes = keyof typeof EventNames;

// all possible event objects
type Events<T> = T extends EventTypes ? {type: T, payload: Payloads[T]} : never;

// all possible callbacks
type Callback<T> = T extends EventTypes ? (event: Events<T>) => void : never;

// all possible subjects
type Subjects<T> = T extends EventTypes ? Subject<Events<T>> : never;

const collection = new Map<EventTypes, Subjects<EventTypes>>();

function fnWithConstraint<T extends EventTypes>(
    name: T,
    cb: Callback<T>
): Subscription | null {
    const subject = collection.has(name)
        ? collection.get(name)
        : new Subject<Events<T>>();
    
    if (subject !== undefined) {
        collection.set(name, subject); 
        /* ^ Type '{ type: "a"; payload: string; }' is not assignable 
           to type 'Events<T>'.
         */
        const subscription = subject.subscribe(cb)
        /* ^ This expression is not callable. Each member of the union 
           type '{ (observer?: Partial<Observer<Events<T>>> | undefined): 
           Subscription; (next: (value: Events<T>) => void): Subscription; 
           ... 1 more ...' has signatures, but none of those signatures 
           are compatible with each other.
         */
        return subscription;
    }

    return null;
}

fnWithConstraint("b", (event) => console.log(event));
// expect typeof event -> { type: "b"; payload: number; }

I can't get this to compile successfully. The TS Playground I've linked to shows the correct result that I'm after on line:45, but the compiler is complaining about the Subjects type signature, and I can't seem to resolve it. What am I missing?

CodePudding user response:

You just need to overload your function:

import { Subject, Subscription } from "rxjs";

const EventNames = {
  a: "a",
  b: "b",
  c: "c",
} as const;

type Payloads = {
  [EventNames.a]: string;
  [EventNames.b]: number;
  [EventNames.c]: boolean;
};

type EventTypes = keyof typeof EventNames;

// all possible event objects
type Events<T extends EventTypes> = { type: T, payload: Payloads[T] }

// all possible callbacks
type Callback<T extends EventTypes> = (event: Events<T>) => void

// all possible subjects
type Subjects<T extends EventTypes> = Subject<Events<T>>

const collection = new Map<EventTypes, Subjects<EventTypes>>();

function fnWithConstraint<T extends EventTypes>(
  name: T,
  cb: Callback<T>
): Subscription | null
function fnWithConstraint(
  name: EventTypes,
  cb: Callback<EventTypes>
): Subscription | null {
  const subject = collection.has(name)
    ? collection.get(name)
    : new Subject<Events<EventTypes>>()

  if (subject !== undefined) {
    collection.set(name, subject);
    return subject.subscribe(cb)
  }
  return null

}

fnWithConstraint("b", (event) => console.log(event));

Playground

In this case you should loose the strictness a bit. From the outside, this function now is safe.

Your example don't work because you use T which is a subtype. IT does not mean that T is equal to a | b | c it just means that T might be any subtype of this union.

Consider this example:

function fnWithConstraint<T extends EventTypes,>(
  name: T,
  cb: Callback<T>
): Subscription | null {
  const subject = collection.has(name)
    ? collection.get(name)
    : new Subject<Events<T>>()

  if (subject !== undefined) {
    collection.set(name, subject);
    return subject.subscribe(cb)
  }
  return null
}

declare const a:'a' & {__tag:'Hello'}

fnWithConstraint(a, (event) => console.log(event));

event argument is Events<"a" & { __tag: 'Hello'; }>.

While TS allows you to use const a, it is unsafe to use event argument, fnWithConstraint(a, (event) => console.log(event.type.__tag /** Hello */));

Please keep in mind, that a for typescript is a regular object which can has his own subtypes. See this: keyof 'a'. More than 30 properties.

TS will also allow you to use this branded type with mine solution, but event argument will be valid/safe


can class methods be overloaded in the same way

Yes.

Simple example:

class Foo {
  run(arg: string): number
  run(arg: number): string
  run() {
    return 'NOT IMPLEMENTED' as any

  }
}

const result = new Foo();
result.run(42) // string
result.run('str') // number

Playground

Official overloads documentation

  • Related