Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Extending Union Types Alias in TypeScript?

I'm trying to limit some string fields to be only of certain values at compile time. The problem is that those values should be extendable. Here's a simplified example:

type Foobar = 'FOO' | 'BAR';

interface SomeInterface<T extends Foobar> {
  amember: T;

  [key: string]: string; // this really has to stay
}

// let's test it

const yes = {
    amember: 'FOO'
} as SomeInterface<'FOO'>; // compiles as expected

//    const no = {
//        amember: 'BAZ'
//    } as SomeInterface<'BAZ'>; // Type '"BAZ"' does not satisfy the constraint 'Foobar' as expected

// so far so good
// Now the problem

abstract class SomeClass<T extends Foobar> {
  private anotherMember: SomeInterface<T>;
}

type Foobarbaz = Foobar | 'BAZ';

class FinalClass extends SomeClass<Foobarbaz> { //no good anymore
}

The error is

Type 'Foobarbaz' does not satisfy the constraint 'Foobar'. Type '"BAZ"' is not assignable to type 'Foobar'.

So the question is: how in typescript can I limit a 'type' to be of certain strings only, but have it extendable with other strings? Or is this an XY problem and there's an obvious better solution?

Typescript 2.3.4 but i think i can upgrade to 2.4 if there's magic there.

like image 807
durilka Avatar asked Aug 17 '17 21:08

durilka


3 Answers

I think you're using the word "extendable" in a different sense from what the keyword extends means. By saying the type is "extendable" you're saying you'd like to be able to widen the type to accept more values. But when something extends a type, it means that you are narrowing the type to accept fewer values.

SomeInterface<T extends Foobar> can essentially be only one of the following four types:

  • SomeInterface<'FOO'|'BAR'>: amember can be either 'FOO' or 'BAR'
  • SomeInterface<'FOO'>: amember can be only 'FOO'
  • SomeInterface<'BAR'>: amember can be only 'BAR'
  • SomeInterface<never>: amember cannot take any value

I kind of doubt that's actually what you want, but only you know that for sure.


On the other hand, if you want SomeInterface<T> to be defined such that T can always be either FOO or BAR, but also possibly some other string values, you want something that TypeScript doesn't exactly provide, which would be specifying a lower bound for T. Something like SomeInterface<TsuperFoobar extends string>, which isn't valid TypeScript.

But you probably only care about the type of amember, and not T. If you want amember to be either FOO or BAR, but also possibly some other string values, you could specify it like this:

interface SomeInterface<T extends string = never> {
  amember: Foobar | T;
  [key: string]: string; 
}

where T is just the union of extra literals you'd like to allow. If you don't want to allow any extra, use never, or just leave out the type parameter (since I've put never as the default).

Let's see it in action:

const yes = {
    amember: 'FOO'
} as SomeInterface; // good, 'FOO' is a Foobar

const no = {
    amember: 'BAZ'
} as SomeInterface; // bad, 'BAZ' is not a Foobar

abstract class SomeClass<T extends string> {
  private anotherMember: SomeInterface<T>;
}

class FinalClass extends SomeClass<'BAZ'> { 
} // fine, we've added 'BAZ'

// let's make sure we did:
type JustChecking = FinalClass['anotherMember']['amember']
// JustChecking === 'BAZ' | 'FOO' | 'BAR'

Did I answer your question? Hope that helps.

like image 77
jcalz Avatar answered Nov 03 '22 05:11

jcalz


To achieve what you need, you can use intersection via the & symbol.

type Foobar = 'FOO' | 'BAR';
type FoobarBaz = Foobar | 'BAZ'; // or: 'BAZ' | Foobar

Example of intersected type

like image 37
Dom Avatar answered Nov 03 '22 05:11

Dom


how in typescript can I limit a 'type' to be of certain strings only, but have it extendable with other strings?

No idea what you X problem is, but you always can introduce another generic parameter, and limit a type by declaring that it extends that parameter. Typescript 2.3 supports default types for generic parameters, so with Foobar as default you can use SomeInterface with one argument as before, and when you need it to extend something else you provide that explicitly:

type Foobar = 'FOO' | 'BAR';

interface SomeInterface<T extends X, X extends string=Foobar> {
  amember: T;

  [key: string]: string; // this really has to stay
}

// let's test it

const yes = {
    amember: 'FOO'
} as SomeInterface<'FOO'>; // compiles as expected


abstract class SomeClass<T extends X, X extends string=Foobar> {
  private anotherMember: SomeInterface<T, X>;
}

type Foobarbaz = Foobar | 'BAZ';

class FinalClass extends SomeClass<Foobarbaz, Foobarbaz> { 
}

UPDATE

I think I understand the problem now. One solution is to encode union types like Foobar as keyof of some artificial interface type used to represent keys only (value types are not used and do not matter). That way, by extending the interface you sort of "naturally" extend the set of keys:

interface FoobarKeys { FOO: { }; BAR: { } };

type Foobar = keyof FoobarKeys;

interface SomeInterface<X extends FoobarKeys = FoobarKeys> {
  amember: keyof X;

  [key: string]: string; // this really has to stay
}



abstract class SomeClass<X extends FoobarKeys = FoobarKeys> {
    protected anotherMember: SomeInterface<X> = {
        amember: 'FOO'
    };

    protected amethod(): void { 
        this.bmethod(this.anotherMember); // no error
    }

    protected bmethod(aparam: SomeInterface<X>): void { 

    }
}

// let's extend it

interface FoobarbazKeys extends FoobarKeys { BAZ: {} };
type Foobarbaz = keyof FoobarbazKeys;

class FinalClass extends SomeClass<FoobarbazKeys> {
    private f() {
        this.bmethod({amember: 'BAZ'})
    } 
}
like image 26
artem Avatar answered Nov 03 '22 05:11

artem