Home > Blockchain >  Typescript dynamic class functions from generics
Typescript dynamic class functions from generics

Time:04-27

I want to do something like

class C<R extends Record<string, unknown>> {
  [K extends keyof R](param: R[K]) {
    console.log(K, param); // have access to both key and value
  }
}

Is there a way to achieve this with typescript?

Currently I have something like

class C<R extends Record<string, unknown>> {
  f<K extends keyof R>(k: K, param: R[K]) {
    console.log(k, param);
  }
}

and I want to see if I can get rid of the f since it's redundant/semantically not useful in my use case.

I read this post, but it only generates the names and not the implementations of the functions.

CodePudding user response:

I'm going to call your "current" class C<R>:

class C<R extends Record<string, unknown>> {
  f<K extends keyof R>(k: K, param: R[K]) {
    console.log(k, param);
  }
  z = 1; // example of some other property of C
}

and we can plan to define a new class D<R> in terms of it that behaves how you want. The idea will be that D in some sense extends C... so that every property of C is also a property of D (I've added an extra property for that reason), but D<R> will also have all the properties from R and will dispatch them to the f method of C. That is, new D<{a: string}>().a("hello") should act the same as new C<{a: string}>().f("a", "hello").


TypeScript will only allow you to declare classes or interfaces with "statically known" keys. Since R is a generic type parameter, then keyof R is not "statically known" so the compiler won't let you just use a class statement directly for this purpose. Instead, you can describe the type of your D<R> class and then use something like a type assertion to claim that some constructor has the right type.

It will look like

type D<R extends Record<string, unknown>> =
  C<R> & { [K in keyof R]: (param: R[K]) => void };

const D = ... as new <R extends Record<string, unknown>>() => D<R>;

where the stuff in ... is the implementation. So this will work, but it's somewhat ugly (since it forces you to write out the type explicitly) and error prone (since the compiler can't verify type safety here).


Next, you can't implement a class directly that will work this way. If you have an instance i of your desired class D<R>, and you call i.k(x) where "k" is in keyof T, you want the instance to dispatch the call to something like f("k", x). Unless you have a list of all possible keys of R at runtime somewhere, you can't make real methods for all of them on the prototype of D. That implies the methods of D need to have dynamic names, and that's not something a class normally does.

If you want dynamic property names in JavaScript, you will want to use a Proxy object which lets you write a get() handler to intercept all property gets. If you make the D class constructor return a Proxy then a call to new D() will produce such a proxy (normally you don't return anything from class constructors, but if you do then new will give you the returned thing instead of this).

So here's one way to do it:

const D = class D {
  constructor() {
    return new Proxy(new C(), {
      get(t, k, r) {
        return ((typeof k === "string") && !(k in t)) ?
          (param: any) => t.f(k, param)
          : Reflect.get(t, k, r);
      }
    });
  }
} as new <R extends Record<string, unknown>>() => D<R>

If k is a key already present in the C instance, then you'll get the property at that key (hence the Reflect.get() call). Otherwise, you get a callback function which dispatches its parameter to the f() method of the C instance.

Note that this only works if you can correctly identify which properties should get dispatched to f() at runtime. In this example, we are sending everything which isn't already a property of C. If you have a need for some more complicated dispatching logic, you will need to write that logic yourself somewhere, probably in the form of a bunch of hard-coded property keys. But that's out of scope for the question as asked.

So that might work, but again... it's unusual (Proxy and a return in a constructor is not best practice) and error-prone ( did I implement it right? ).


Let's see if it works:

const d = new D<{ a: string, b: number, c: boolean }>();

d.a("hello") // "a", "hello"
d.b(123); // "b", 123
console.log(d.z.toFixed(2)) // 1.00

Yay, it worked. But I'm not sure it's worth the complexity and fragility of the code just to save a caller a few keystrokes. Personally I'd rather keep the code simple and clean and make people write c.f("a", "hello") instead of d.a("hello"). But it's up to you.


Playground link to code

  • Related