Olian04
Olian04

Reputation: 6872

How do you limit a type to all primitive values and only some function signatures?

I want to construct a type that allows the values of a "class" to be of any primitive type (or any type at all for that matter), however if its a function, then it needs to be of a certain signature, arbitrary function signatures shouldn't be allowed.

type A = ((e: Event) => void) | Exclude<any, (...args: any[]) => any>;
interface IFoo {
  [k: string]: A;
}

class Foo implements IFoo {
  public bar() {
    /*
     This function should be disallowed because its type is not (e: Event) => void)
    */
    return 'blue';
  }
}

I've tried the above; I would expect TS to throw an error when I'm defining bar() but it doesn't.

Is it possible to construct the type I'm after? If so, how? If not, why?

Upvotes: 0

Views: 598

Answers (1)

jcalz
jcalz

Reputation: 328362

Here's how I'd approach it. First, for your A (which I'll call AllowedProperties):

type AllowedProperties =
  | ((e: Event) => void) // function that takes an event
  | { call?: never } // an object which is not a function
  | string
  | number
  | symbol
  | boolean
  | null
  | undefined;

That's a union of: primitives, the function type you want (e: Event) => void, and an object type without a property named call. This last thing matches most object types, but excludes function types, since functions have a call method. TypeScript doesn't have negated types, so Exclude<any, Function> doesn't do anything useful (it becomes any).

Then we have Foo implement Record<keyof Foo, AllowedProperties>, which will cause the compiler to check each property (and not require an index signature in the class).

class Foo implements Record<keyof Foo, AllowedProperties> {

Let's look at how it works. The following are all fine:

  str: string = "a";
  num: number = 1;
  boo: boolean = true;
  nul: null = null;
  und: undefined = undefined;
  obj: object = {};
  sym: symbol = Symbol("hmm");

And the method you want is also fine:

  methodTakingEvent(e: Event) {} // okay

A method taking a non-Event compatible parameter is an error, as desired:

  methodTakingString(x: string) {} // error!
  //~~~~~~~~~~~~~~~~ <-- Event is not assignable to string

A method requiring more than one parameter is also an error, as desired:

  methodTakingTooManyArguments(e: Event, x: string) {} // error!
  //~~~~~~~~~~~~~~~~~~~~~~~~~~
  //Type '(e: Event, x: string) => void' is not assignable to type '(e: Event) => void'.

Now for some possibly surprising things... A method taking a parameter of a type wider than Event is fine, since it will be happy with an Event:

  methodTakingUnknown(x: unknown) {} // okay
  // Event is assignable to unknown

A method of no arguments is also fine, because functions with fewer parameters are assignable to functions that take more parameters.

  noArgMethod() {} // okay

Additionally a method returning something is fine, because functions returning non-void types are assignable to functions returning void.

  methodReturningSomething(e: Event) {
    return "okay";
  } // okay

Both of those last two put together means it's harder to outlaw your bar() method. I'll get back to that in a bit.

Also, if you happen to want an object with a call property, it will also fail:

  pathologicalCase = { call: 12345 }; // error!
  //~~~~~~~~~~~~~~
  // Type '{ call: number; }' is not assignable to type '{ call?: undefined; }'.

That might not be an issue; if it is, you can probably find some other property of functions that you don't need to use (e.g., bind or apply). Or maybe you don't need all three, so you change {call?: never} to {call?: never} | {apply?: never} | {bind?: never}. The point is to make some type which allows most object types you care about but still excludes functions. Without negated types, the closest you can get is to build up a list of acceptable non-functions instead of "negating" functions.



I'd be inclined to leave it there, but if you really need to prevent methods of no-arguments, then you could use a relatively complex conditional and mapped type to express a generic constraint:

type AllowedProperties<T> = {
  [K in keyof T]: T[K] extends (...args: infer A) => void
    ? 1 extends A["length"] ? (Event extends A[0] ? T[K] : never) : never
    : T[K] extends Function ? never : T[K]
};

class Foo implements AllowedProperties<Foo> {

By constraining Foo to implement AllowedProperties<Foo>, we can look at each property of Foo and make sure it matches what is spit out of AllowedProperties<Foo>. Roughly, for each property, if it's a function then its parameter list must be of length exactly one, and Event must extend that only parameter... otherwise, the property is invalid. If the property is not a function, then it's valid. Let's see it work:

  str: string = "a";
  num: number = 1;
  boo: boolean = true;
  nul: null = null;
  und: undefined = undefined;
  obj: object = {};
  sym: symbol = Symbol("hmm");

  methodTakingEvent(e: Event) {} // okay

  methodTakingString(x: string) {} // error!
  //~~~~~~~~~~~~~~~~ <-- '(x: string) => void' is not assignable to type 'never'

  methodTakingUnknown(x: unknown) {} // okay
  // Event is assignable to unknown

  noArgMethod() {} // error!
  //~~~~~~~~~
  // Type '() => void' is not assignable to type 'never'.

  methodReturningSomething(e: Event) {
    return "okay";
  } // still okay

  methodTakingTooManyArguments(e: Event, x: string) {} // error!
  //~~~~~~~~~~~~~~~~~~~~~~~~~~
  //Type '(e: Event, x: string) => void' is not assignable to type 'never'.

  pathologicalCase = { call: 12345 }; // okay
}

Now noArgMethod() is rejected (which would prohibit your bar()) and pathologicalCase is allowed (if it matters), as presumably desired. The error messages on invalid function properties are a little weird (is not assignable to type 'never') but maybe that's worth it to you.


So I guess it depends on whether you want to use the more simple code that plays nicely with the compiler but doesn't exclude no-arg methods, or the more complex code that fights against the compiler but behaves more closely to what you asked for.

Either way, hope this answer helps. Good luck!

Link to code

Upvotes: 1

Related Questions