1

I'm trying to build a generic function that ensures specific properties of a class are set at initialization time. (Normally I'd just do this by making params required, but sometimes when you're working with dynamic data, that's not always possible!)

export const requireParams = (cls: AnyClass, requiredParams: string[]) => {
  if (!requiredParams) {
    return;
  }

  const missingParams = requiredParams.filter(
    (param) => cls?.[param] === undefined || cls?.[param] === null || cls?.[param] === NaN
  );
  if (missingParams.length) {
    throw new Error(`Missing required params: ${missingParams.join(", ")}`);
  }
};

I'd then use it like:

class Foo {
  constructor(public readonly food: string) {

    requireParams(this, ['food']);

  }
}

AnyClass here is theoretical — and while I could make requireParams generic and always pass in the type of cls by <T>(cls: AnyClass<T>), this is kinda pedantic and in my brain, you should be able to infer T from the type of cls?

I tried some wacko things like:

type AnyClass<T> = T extends new (...args: any[]) => infer R ? R : never;

(but always returns T as never)

And I tried a bunch of other crazy ideas to try and infer T from the instance, like:

type GenericOf<T> = T extends new (...args: any) => InstanceType<infer X> ? X : T;
type AnyClass<T = any> = GenericOf<T extends new (...args: any) => infer X ? X : never>;

But it still comes back to the default value for T being any, and thus assumes T is unknown.

(This is, ultimately, because I don't know what I'm doing).

It also seems like I am grossly overthinking this - perhaps I need to type requireParams<T> and give T a default value (and enforce that it is a class?)

Is this possible? If so, how? Or am I falling victim to this problem?

brandonscript
  • 68,675
  • 32
  • 163
  • 220
  • It is not really clear to me what behaviour you want so see here. Maybe you just want to allow `T` to be anything and then use `keyof T` for the `requiredParams` as seen [here](https://tsplay.dev/WvG9Aw). Would this work for you? – Tobias S. Oct 29 '22 at 20:51
  • Yeah, that looks just about about right! Only difference is I want to be able to enforce that `cls` is actually a class, not just any object or primitive. – brandonscript Oct 29 '22 at 22:41
  • @brandonscript How is that supposed to work? You get the *instance* inside the function, not the class itself. – kelsny Oct 29 '22 at 22:47
  • @brandonscript - I don't think you can limit a function to only take in class instances with TypeScript alone. After all, class instances are just objects and the type system does not seem to see (AFIK) any difference between random objects and class instances. You can limit `T` to extend `object` but you would have to check at runtime via the prototype if the input is actually a class instance. – Tobias S. Oct 29 '22 at 22:47
  • That's what I thought too, but there are quite a few answers like this one: https://stackoverflow.com/questions/39392853/is-there-a-type-for-class-in-typescript-and-does-any-include-it that suggest you can. Weirdly though, it won't validate for me, so idk. – brandonscript Oct 29 '22 at 22:50
  • @brandonscript - This question is about accepting *classes*, but not about *class instances* – Tobias S. Oct 29 '22 at 22:56
  • HA! Got it: `type Class = new (...args: any[]) => T;` `export const requireParams = >(...` If you want to toss in an answer I can update it with my edits and accept it. Thanks! – brandonscript Oct 30 '22 at 00:04
  • @brandonscript - I just tried that, see my answer. It does not work for me. Could you share a playground? – Tobias S. Oct 30 '22 at 00:13

1 Answers1

1

Simplyfing down the problem to just use T directly seems to (almost) solve your problem.

export const requireParams = <T,>(instance: T, requiredParams: (keyof T)[]) => {
  if (!requiredParams) {
    return;
  }

  const missingParams = requiredParams.filter(
    (param) => instance?.[param] === undefined || instance?.[param] === null || parseInt(instance?.[param]?.toString()) === NaN
  );
  if (missingParams.length) {
    throw new Error(`Missing required params: ${missingParams.join(", ")}`);
  }
};

Playground


But you also want to constrain T be a class instance. AFAIK, there is nothing in the type system differentiating a class instance from any other object. I just tried your comment, but it seemingly does not to work for me :/

export type AnyClass = new <T extends { constructor: Function }>(...args: (keyof T)[]) => T;

const requireParams = <T extends InstanceType<AnyClass>>(
  instance: T,
  requiredParams?: (keyof T)[]
) => {
  // ...
};

class Foo {
  food: string;
  constructor(food: string) {
    this.food = food;
    requireParams(this, ["food", "bar"]);
    //                           ~~~~~
  }
}

requireParams({}, ["food", "bar"]);
//                 ~~~~~~  ~~~~~
// This should complain on passing an empty {}, but doesn't.
// At least its params are caught as invalid though.

interface TastySnack {
  snacks: string;
}

requireParams({ snacks: "crisps" } as TastySnack, ["snacks", "bar"]);
//                                                           ~~~~~

Playground

OP's Playground

brandonscript
  • 68,675
  • 32
  • 163
  • 220
Tobias S.
  • 21,159
  • 4
  • 27
  • 45
  • Yeah turns out it _compiles_, but it doesn't actually constrain it. This is a pretty great answer though, and class or not, at least using the `keyof T` it guarantees type checking. – brandonscript Oct 30 '22 at 01:24
  • Updated your answer with my annotations, feel free to do with it what you like! – brandonscript Oct 30 '22 at 02:20