1

I need help ensuring typesafety across the following generic abstract class, that uses a proxy:

proxy.ts

function authHandler<
  F extends Families,
  T extends controllerAbstract<F>
>(): any {
  return {
    get: function (target: T, prop: keyof T): any {
      return (...args: any[]) => {
        target.authorize((controllerClient: Client<F>) => {
            
          return target[prop](controllerClient, args);
        });
      };
    },
  };
}

controllerAbstract.ts

abstract class controllerAbstract<F extends Families> {
  controllerFamily: F;

  constructor(controllerFamily: F) {
    this.controllerFamily = controllerFamily;
    return new Proxy(this, authHandler<F, typeof this>());
  }

  abstract build_client(): Client<F>;
   
  authorize(consume_client: (client: Client<F>) => typeof this[keyof typeof this]) {
    consume_client(this.build_client());
  }
}

concrete.ts


const controllerFamilyName = 'FamilyA';
type ControllerFamliy = typeof controllerFamilyName;

class FamilyAController extends controllerAbstract<ControllerFamliy> {
  constructor() {
    super(controllerFamilyName);
}

  build_client(): Client<ControllerFamliy> {
    // build a client of type "FamilyA"
    const client = { id: 'A' } as Client<ControllerFamliy>;
    return client; 
  }

  // proxy "authorize" provides client object
  useClient(client: any, args: any[]) {
    // i want client to implicitly be of type "FamilyAClient_t" / Client<"FamilyA">
    // currently just using "any" as placeholder
  }
}

types.ts

type FamilyAClient_t = { id: 'A' };

type FamilyBClient_t = { id: 'B' };

interface ClientTable {
  FamilyA: FamilyAClient_t;
  FamilyB: FamilyBClient_t;
}

type Families = keyof ClientTable;

type Client<F extends Families> = ClientTable[F];

N.B where i say "concrete" class, i mean the contents of concrete.ts, i.e. that being a class extending the abstract class, with FamilyAController just being an example of this.

My goal is to make the client parameter of the useClient method in FamilyAController of concrete.ts to reflect the correct type based on the concrete class's controllerFamilyName const, such that i can call client.id, and i get the correct autocomplete of "A" if controllerFamilyName == 'FamilyA', and "B" if controllerFamilyName == 'FamilyB'.

I have changed the explicit types in the proxy to try and get the types to be recognized as i need, based on the solution here. This is where i think the type safety breaks down in my implementation.

However, still this is erroneous, as:

  • the proxy target[prop](...) line is giving an error of "this expression is not callable", as target[prop] is of type unknown. This is because not every prop of target is indeed callable, but some are, and i want to be able to restrict the type of target to this subset.
  • my attempt at defining a return type for the consume_client function argument of the abstract class authorize method: authorize(consume_client: (client: Client<F>) => typeof this[keyof typeof this]) . is throwing error. The intention is for it to return the type any method return type defined in concrete. For example, if another method existed on the concrete class, then the consume client return type should be able to automatically handle this new method return type. Hopefully you can see what my intention was here - to effectively "see through" the proxy layer to maintain the same return type that would exist based on any callable method of the concrete class.

Another trialed method was to define another abstract class containing just the abstract methods which are consistent with target[props](controllerClient) signature, and use the keyof this class as the props type, to fix thefirst error. This was successful, but i am still unsure how to reach my final goal.

Essentially what i want is, in concrete.ts , to be able to write useClient(client) and the client type be implicitly Client<ControllerFamily> so i don't have to write useClient(client: Client<ControllerFamily>) for each method of each concrete class variation. Is this possible?

Hopefully this helps to clarify my objective with the code, and the current state of my progress with it.

Chris24t
  • 27
  • 4
  • Please [edit] the code so that it's a self-contained [mre] suitable for pasting into a standalone IDE. Right now it looks like you've got undeclared types and values, and even some invalid syntax, so I'm faced with errors completely unrelated to your problem that I'd need to fix before even getting to the starting line here. If you do make the changes to the question so that it's easy for others to get to work on it, and you want me to take another look, please mention @jcalz in a comment to notify me. Good luck! – jcalz Nov 25 '22 at 01:30
  • Does [this approach](https://tsplay.dev/w1A8Yw) meet your needs? I'm not sure why you decided to annotate the type of `controllerFamilyName` explicitly, but if you let the compiler infer the type, everything works. (Also note that I collapsed that into one file since multi-file code is harder to debug/copy/paste and unless you're asking about multiple files it is only distracting. Also fixed spelling from Famliy to Family. You might want to make these edits yourself). If this satisfies your needs I'll write up an answer explaining; if not, what am I missing? – jcalz Nov 25 '22 at 21:03
  • I very much appreciate the time you are spending to help me. I have been looking at it today and reached the same conclusion as you to remove the explicit typing. However, this just pushed my issue further down as the desired outcome is still not present. Ultimately, i want the `client` parameter of `useClient(client)` to *implicitly* be of type `FamilyAClient_t` if `controllerFamilyName == "FamilyA"`, and `FamilyBClient_t` if `controllerFamilyName == "FamilyB"`. I will update the post with my latest attempt at this and my thoughts, and incorporate suggested changes. – Chris24t Nov 25 '22 at 22:57
  • Okay, well, leave a comment mentioning @jcalz when you want me to look again (otherwise I'm likely to forget about this... last time I happened to check back again despite the lack of a comment, but you can't rely on that) – jcalz Nov 25 '22 at 23:37
  • Hopefully the updated question is more reflective of my problem. Please see the penultimate paragraph for a general gist of what i want. @jcalz – Chris24t Nov 26 '22 at 01:51
  • This really feels like scope creep; we moved past one problem and hit a different one whose relation to the first is only because it's in your code. I'm concerned that if I spend time and effort trying to resolve this one, you'll edit the question again with the next problem you run into. Can you commit not to move the goal here? – jcalz Nov 26 '22 at 02:16
  • So, [this](https://tsplay.dev/mp8lbm) is the closest I can get. There is no such thing in TypeScript as class methods getting contextually typed parameters (you're calling this "implicitly"). You get type *checking*, but not contextual typing. There have been various requests for such typing, and attempts to implement it (see [ms/TS#23911](//github.com/microsoft/TypeScript/issues/23911)) but so far, at least, it's not part of the language. Anyway, does this fully address your question? If so I can write up an answer explaining, if not, what am I missing? (Again, say @jcalz to ping me) – jcalz Nov 26 '22 at 02:34
  • Yes, thank you very much for clarifying this and for your help. @jcalz – Chris24t Nov 26 '22 at 03:03
  • Okay I will write up an answer when I get a chance; it might not be until tomorrow – jcalz Nov 26 '22 at 03:07

1 Answers1

1

Unfortunately TypeScript cannot currently give class method parameters "implicit" or "contextual" types inherited from superclasses. You are required to manually annotate all method parameters, and these can then be checked against the superclass, so you'll get an error if you do it wrong. But you still have to do it manually. This is definitely annoying. There have been numerous feature requests to improve it (see microsoft/TypeScript#23911 for example), but none of these have made it into the language. There always seems to be a problem with performance, or breaking real world code. For now, the best you can do will be to manually annotate.


That being said, the closest I can get to what you want is to try to tell the compiler what ControllerAbstract actually does... which is that any added method must take two arguments, the first is of type Client<F> for the relevant generic type argument F.

I say "try" to tell because there's no perfect way to do this. No direct support exists to describe "all strings except for the known keys", so there's no way to say "any property on ControllerAbstract<F> except for "controllerFamily", "buildClient", and "authorize"". There's a longstanding open feature request for this at microsoft/TypeScript#17867 but who knows when or if it will be implemented. For now there are only workarounds (see How to define Typescript type as a dictionary of strings but with one numeric "id" property for a list of these).

One such workaround is to intersect the known part of the object with an index signature for the "everything else" part. Like this:

abstract class _ControllerAbstract<F extends Families> {
  // ✂ snip ✂
}

type ControllerAbstract<F extends Families> = _ControllerAbstract<F> &
  { [k: string]: (client: Client<F>, args: any[]) => any }

But this is untrue; it implies that, say, the buildClient property will be both a method of type () => Client<F> and a method of type (client: Client<F>, args: any[]) => any. And the compiler knows that's not true, so you can't just add the index signature to the class body directly. Instead you need to do what I did above: rename the actual class body out of the way, and use a type assertion to convince the compiler that ControllerAbstract is a constructor that behaves as desired:

const ControllerAbstract = _ControllerAbstract as abstract new
  <F extends Families>(controllerFamily: F) => ControllerAbstract<F>;

Well, let's see if it works:

const controllerFamilyName = 'FamilyA';
type ControllerFamily = typeof controllerFamilyName;    
class FamilyAController extends ControllerAbstract<ControllerFamily> {
  constructor() {
    super(controllerFamilyName);
  }

  build_client() {
    return { id: "A" } as const;
  }

  useClient(client: Client<ControllerFamily>, args: any[]) { }

  useClientBad(client: Client<"FamilyB">, args: any[]) { }
  // Property 'useClientBad' of type '(client: FamilyBClient_t, args: any[]) => void' is
  // not assignable to 'string' index type '(client: FamilyAClient_t, args: any[]) => any'.(2411)
}

Looks good. The useClient() method type checks, while the useClientBad() method does not, and the error there describes the problem, that client is FamilyBClient_t when it should be FamilyAClient_t.


So, there you go. It's not perfect, but it's the closest I can get.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360