3

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.

15
  • 1
    I do want to store some variables/state in the class though. Also name collisions aren't a big deal because it's more meant for a few internal use cases. This isn't a super must have feature, it's moreso nice syntactic sugar, and I'm just wondering if its possible. Commented Apr 26, 2022 at 18:32
  • 2
    You can write a Proxy but it's not pretty, see here. Would that approach meet your needs, or am I missing something? Commented Apr 26, 2022 at 18:45
  • 1
    If you don't mention me via "@jcalz" then Stack Overflow is not guaranteed to let me know. I'll write something up when I get a chance, though. Commented Apr 26, 2022 at 19:21
  • 1
    Sorry, I didn't realize it would impact the solution until now :( I'm still happy to give an upvote/accept an answer for the original question Commented Apr 26, 2022 at 20:28
  • 1
    You're going to need some runtime artifact that lets you figure out if a key belongs to f or g. In practice, presumably C actually gets constructed with some data structure with the appropriate keys. (I mean, if f() and g() are supposed to be able to produce arbitrary responses from arbitrary inputs then you probably need to pass those functions in). Something like this is just an example; perhaps with a minimal reproducible example that demonstrates an actual use case I could suggest something more appropriate. Commented Apr 26, 2022 at 21:04

1 Answer 1

4

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

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.