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.
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.