dipea
dipea

Reputation: 566

How can I conditionally include methods on a class based on a type parameter?

I can use conditional types to conditionally include methods on an interface:

interface Iter<T> {
  flatten: T extends Iterable<infer A> ? () => Set<A> : never;
}

Is there a way of doing this with a class as well?

Upvotes: 1

Views: 965

Answers (1)

jcalz
jcalz

Reputation: 328618

Assuming you're using the --strict suite of compiler features for a "standard" level of safety, or at least the --strictPropertyInitialization compiler option, then you need to initialize any class member you have.

But it's essentially impossible for the compiler to verify that a value is assignable to a conditional type that depends on a generic type parameter; it just defers evaluation of the type until such time as the generic type parameter is specified. Something like T extends Iterable<infer A> ? () => Set<A> : never; is therefore opaque to the compiler, and so there's not really anything you can assign to flatten without using a type assertion to quell the compiler's concerns about type safety.

So you could do something like this:

class Iter<T> {
  constructor(public prop: T) { }
  flatten = (function (this: Iter<T>) {
    return new Set(this.prop as Iterable<any>)
  } as T extends Iterable<infer A> ? () => Set<A> : never;
}

which works:

const a = new Iter(123);
try {
  const oops = a.flatten(); // error! 
  // ----------> ~~~~~~~
  // never has no call signatures
} catch (e) {
  console.log(e); // this.prop is not iterable
}

const b = new Iter([1, 2, 3]);
const okay = b.flatten();
// const okay: Set<number>

but it's clunky and weird, even aside from the type assertion. You don't really want flatten to be an instance property, do you? It's more like a method that lives on the class prototype. And you're lying to the compiler because when T is not iterable, then you claim that flatten is of type never which is impossible.


Instead, I'd think what you actually want is for flatten to be a method that always exists, but is only safe to call when T is iterable. You can get that effect with a this parameter:

class Iter<T> {
  constructor(public prop: T) { }
  flatten<A>(this: Iter<Iterable<A>>) {
    return new Set(this.prop)
  }
}

That compiles without type assertions or conditional types, and is verified as safe by the compiler. The return type of flatten is Set<A>, when called on an Iter<T> which is assignable to Iter<Iterable<A>>.

This behaves similarly from the caller's side, and the error is more descriptive of the issue (it isn't that flatten is never, but that a is not an Iter<Iterable<A>>):

const a = new Iter(123);
try {
  const oops = a.flatten(); // error! 
  // --------> ~
  // The 'this' context of type 'Iter<number>' is not assignable 
  // to method's 'this' of type 'Iter<Iterable<unknown>>'.
} catch (e) {
  console.log(e); // this.prop is not iterable
}

const b = new Iter([1, 2, 3]);
const okay = b.flatten();
// const okay: Set<number>

Playground link to code

Upvotes: 4

Related Questions