Inferring a class member’s type based on another member

  Kiến thức lập trình

I’ve got a TypeScript class Foo with fieldOne: number | string and fieldTwo which will be of type Bar[] when fieldOne is a number and Baz[] when fieldOne is a string. Both fields are provided as constructor parameters.

Most of the functionality in the class is based on the union of Bar and Baz, but in one place I need to narrow the type to Bar or Baz. Is there a way I can use the compiler to enforce that Foo can only be instantiated with a number and Bar[] or a string and Baz[]. Then, within the class can I narrow the type of fieldTwo to Bar[] or Baz[] when I need to?

I can achieve the effect with inheritance as shown here.

interface Bar {
  foo: string;
  bar: number;
}

interface Baz {
  foo: string;
  baz: number;
}

abstract class Foo<T extends number | string, U extends Bar[] | Baz[]> {
    private fieldOne: T;
    protected fieldTwo: U;

    constructor(paramOne: T, paramTwo: U) {
        this.fieldOne = paramOne;
        this.fieldTwo = paramTwo;
    }
    
    generalFunction() {
        console.log(this.fieldTwo.map(x => x.foo).join());
        this.specificFunction();
    }
    
    protected abstract specificFunction(): void;
}

class FooBar extends Foo<number, Bar[]> {
    specificFunction() {
        console.log(this.fieldTwo.map(x => x.bar).join());
    }
}

class FooBaz extends Foo<string, Baz[]> {
    specificFunction() {
        console.log(this.fieldTwo.map(x => x.baz).join());
    }
}

but I it’s a bit long winded.

I’d hoped to get something more succinct with conditional types as here

type FooContents<T extends number | string> = T extends number ? Bar : Baz;

class Foo<T extends number | string> {
  private fieldOne: T;
  private fieldTwo: FooContents<T>;

  constructor(param1: T, param2: FooContents<T>) {
    this.fieldOne = param1;
    this.fieldTwo = param2;
  }

  generalFunction() {
    console.log(this.fieldTwo.foo);
  }

  specificFunction() {
    // somehow narrow the type, maybe based on this.fieldOne?
    // if fieldOne is number do something with Bar[];
    // if fieldOne is string do something with Baz[];
  }
}

but I can’t get it to work like I need.

A third way, here, combines the two fields into a single object.

type FooParams = {one: number, two: Bar[]} | {one: string, two: Baz[]};
class Foo {
    params: FooParams;

    constructor(params: FooParams) {
      this.params = params;
    }
    
    generalFunction() {
        console.log(this.params.two.map(x => x.foo).join());
    }

    specificFunction() {
      if (isNumberyBary(this.params)) this.processBars(this.params.two);
      if (isStringyBazy(this.params)) this.processBazs(this.params.two);
    }

    processBars(bars: Bar[]) {
        console.log(bars.map(x => x.bar).join());
    }

    processBazs(bazs: Baz[]) {
        console.log(bazs.map(x => x.baz).join());
    }
}

function isNumberyBary(x: FooParams): x is {one: number, two: Bar[]} {
  return typeof x === 'number';
}

function isStringyBazy(x: FooParams): x is {one: string, two: Baz[]} {
  return typeof x === 'number';
}

but again, it’s more long winded than I’d hoped for.

7

So, you won’t get this to work with generics and conditional types, because no amount of checking a generic value will cause the generic type to change. At least until something like microsoft/TypeScript#33014 is implemented.


The only facility TypeScript has for checking one property (like fieldOne) and using that to narrow the apparent type of a different property (like fieldTwo) is when the object in question is a discriminated union and the first property is its discriminant. Unfortunately, your fieldOne is either string or number, neither of which are considered discriminants. You need at least one of those to be a literal type. You could possibly add a separate property (fieldZero?) that serves that purpose, possibly using true/false:

type FooParams =
  { zero: true, one: number, two: Bar[] } |
  { zero: false, one: string, two: Baz[] };

class Foo {
  constructor(public params: FooParams) {}

  generalFunction() {
    console.log(this.params.two.map(x => x.foo).join());
  }

  specificFunction() {
    if (this.params.zero) {
      this.processBars(this.params.two);
    }
    else {
      this.processBazs(this.params.two);
    }
  }

  processBars(bars: Bar[]) {
    console.log(bars.map(x => x.bar).join());
  }

  processBazs(bazs: Baz[]) {
    console.log(bazs.map(x => x.baz).join());
  }
}

Now FooParams is a discriminated union. This frees you from having to implement custom type guard functions. This version of the code is probably how I’d approach it. A Foo is just a container which holds a FooParams. It might not be how you’d like to represent the data, but it works rather well.


But you want a Foo to be a FooParams, not just hold one. This is much trickier. Class statements don’t make union types in TypeScript. You could express such a thing in the type system, but hooking that type up to an actual class constructor would involve type assertions and other workarounds, and you might run into weird barriers (like you wouldn’t be able to easily subclass Foo). Still, here’s a way to do it:

First you have to rename the Foo class out of the way to _Foo and make it some base supertype of what you want:

class _Foo {

  constructor(
    public fieldZero: boolean,
    public fieldOne: number | string,
    public fieldTwo: Bar[] | Baz[]
  ) { }

  generalFunction() {
    console.log(this.fieldTwo[0].foo);
  }

  processBars(bars: Bar[]) {
    console.log(bars.map(x => x.bar).join());
  }

  processBazs(bazs: Baz[]) {
    console.log(bazs.map(x => x.baz).join());
  }

}

Then you define the different flavors of Foo in terms of it:

interface FooTrue extends _Foo {
  fieldZero: true,
  fieldOne: number,
  fieldTwo: Bar[]
}

interface FooFalse extends _Foo {
  fieldZero: false,
  fieldOne: string,
  fieldTwo: Baz[]
}

Then you define the type Foo as the discriminated union, and the value Foo as the _Foo value but assert it to have an appropriate type:

type Foo = FooTrue | FooFalse;
const Foo = _Foo as {
  new(fieldZero: true, fieldOne: number, fieldTwo: Bar[]): FooTrue,
  new(fieldZero: false, fieldOne: string, fieldTwo: Baz[]): FooFalse
}

Now you can use the single Foo class to behave like a discriminated union. Inside _Foo you can only do that in methods by using a this parameter to convince the compiler that the method will only be called on an object that matches Foo and not just _Foo:

  specificFunction(this: Foo) {
    if (this.fieldZero) {
      this.processBars(this.fieldTwo);
    }
    else {
      this.processBazs(this.fieldTwo);
    }
  }

This all works without error. Is it worth it? I wouldn’t think so. If you want polymorphism with classes, you should just have different subclasses the way your first approach worked. Classes are really geared toward inheritance, not toward alternation within a single class. Or you can have alternation as a union property of a single class. But this hybrid approach of making a single class look like a union is really fighting against the language.

Playground link to code

LEAVE A COMMENT