Lodin
Lodin

Reputation: 2068

Complex function with conditional types in Typescript

I'm trying to add types to a function that takes a list of method names, the original object that may contain or not contain a method from the list, and the fallback object that also can include or not include a method from the list.

The function follows the algorithm for each method in the method list:

  1. If the method exists in the original object, it is added to the result object.
  2. If a method exists in the fallback object, it is added to the result object.
  3. If nothing above exists, the no-op method (empty function) is added to the result object.

I wrote the following code, but it has errors in both code and returns value:

type Reflection<
  PropertyName extends PropertyKey,
  ReflectingObject extends Partial<Record<PropertyName, Function>>,
  FallbackObject extends Record<PropertyName, Function>
> = {
  [Prop in PropertyName]: ReflectingObject[Prop] extends never
    ? FallbackObject[Prop] extends never
      ? () => void
      : FallbackObject[Prop]
    : ReflectingObject[Prop];
  };

const noop = () => { }

const reflectMethods = <
  PropertyName extends PropertyKey,
  ReflectingObject extends Partial<Record<PropertyName, Function>>,
  FallbackObject extends Record<PropertyName, Function>
>(
  reflectingObject: Partial<ReflectingObject>,
  methodNames: PropertyName[],
  fallbacks: Partial<FallbackObject> = {},
): Reflection<PropertyName, ReflectingObject, FallbackObject> =>
  methodNames.reduce<
    Reflection<PropertyName, ReflectingObject, FallbackObject>
  >((reflection, name) => {
    reflection[name] = reflectingObject[name] ?? fallbacks[name] ?? noop; // here the code fails with T2322

    return reflection;
  }, {} as Reflection<PropertyName, ReflectingObject, FallbackObject>);

Also, if I use this function, it will fail if I do not mention any of the methods in the original object or the fallback object though it should have the () => void type.

class A {
    a(aa: number) { 
        return aa;
    }
    b(bb: string) { 
        return bb;
    }
    c() {}
 }

const proto = A.prototype;

const aaaa = reflectMethods(proto, ['a', 'c', 'd'], { a(aa: number) { return aa + 2 } });

aaaa.a(11);
aaaa.d(); // here it fails with T2571

I probably misused the conditional types somehow here, but I'm not sure how to fix them to make them work.

Here is the link to the Typescript playground that demonstrates the issue.

Upvotes: 0

Views: 175

Answers (1)

jcalz
jcalz

Reputation: 329943

Here's the code I ended up with after trying to rationalize what was happening in the example:

const noop = () => { }
type Noop = typeof noop;

type Reflection<
  K extends PropertyKey, R, F> = {
    [P in K]: P extends keyof R ? R[P] : P extends keyof F ? F[P] : Noop;
  };

type Funs<K extends PropertyKey> = { [P in K]?: Function }

const reflectMethods = <K extends PropertyKey, R, F>(
  reflectingObject: R & Funs<K>,
  methodNames: K[],
  fallbacks?: F & Funs<K>,
) =>
  methodNames.reduce((reflection, name) => {
    reflection[name] = reflectingObject[name] ?? fallbacks?.[name] ?? noop;
    return reflection;
  }, {} as Funs<K>) as Reflection<K, R, F>;

Unimportant changes:

  • making type parameters single-character names, as per convention. PropertyName became K, ReflectingObject became R, FallbackObject became F, and Prop became P.

  • adding type aliases Noop for ()=>void and Funs<K> for Partial<Record<K, Function>>.

Important-ish changes:

  • Instead of having F and R constrained to Funs<K>, I just have the reflectMethods() function guarantee that methodNames and fallbacks are assignable to Funs<K> while inferring them as the narrow type that's handed in. Generic constraints sometimes have undesirable behavior of making the type parameter widen all the way to the constraint, which completely defeats your purpose. You don't want R and F to have all the keys from K unless they actually have functions at all those properties. It's also fairly straightfoeward to have the compiler infer a type parameter T given a value of T or even T & SomethingElse, whereas it's less reliable to infer a type parameter T given a value of Partial<T>.

  • Instead of having the conditional type probe for F[P] extends never, I have it probe P extends keyof F. This is necessary if you don't constrain F to Funs<K>, and it's more conventional too (K extends keyof T ? T[K] : SomethingElse is very common: T[K] extends never ? SomethingElse : T[K] is much less common).

  • I've made fallBacks an optional parameter and later, if fallBacks is undefined, I use fallbacks?.[name]. This gives the same results as if you made fallBacks default to {}, but plays more nicely with the type system.

  • Assigning a value to a conditional type that depends on an unspecified generic is often impossible without a type assertion; the compiler just doesn't do much analysis on these types. And Reflection<K, R, F> is such a type inside the implementation of reflectMethods(). Instead of trying to assign reflection[name] where reflection is a Reflection<K, R, F>, I instead let reflection be a Funs and then assert to Reflection<K, R, F> when the value is returned.


Okay, let's see what comes out:

const aaaa = reflectMethods(proto, ['a', 'c', 'd'], { a(aa: number) { return aa + 2 } });
aaaa.a // (aa: number) => number;
aaaa.c // () => void
aaaa.d // () => void

aaaa.a(11); // okay
aaaa.d(); // okay

That all looks good to me. You might want to do more probing of edge cases, but hopefully that gives you some direction. Good luck!

Playground Link

Upvotes: 1

Related Questions