MartaGalve
MartaGalve

Reputation: 1226

Typescript constructor: accept string for enum

I am using TypeScript to define a constructor for a model in Angular. One of the properties in the model is set as an enum with a few possible string values. This works fine if I pass an enum value to the constructor. The problem is that I need call the constructor based on an API response which returns a string for the value that needs to be mapped to that property.

Is there any way of passing a string (as long as it is one of the values defined in the enum) to the constructor?

Enum:

export enum TestTypes {
    FIRST = 'first',
    SECOND = 'second',
}

export class Test {
    this.myType: TestTypes;

    constructor(options: {type: TestTypes}) {
        this.myType = options.type;
    }
}

The following works const a = new Test({type:TestTypes.FIRST});

What I would like to achieve: const b = new Test({type:'first'})

Should I do the following? const b = new Test({type:TestTypes['first']})

Upvotes: 3

Views: 2322

Answers (1)

jcalz
jcalz

Reputation: 329013

The easiest way to go is to change your enum to a straight dictionary like this:

const literal = <L extends string | number | boolean>(l: L) => l;

export const TestTypes = {
  FIRST: literal('first'),
  SECOND: literal('second'),
};

export type TestTypes = (typeof TestTypes)[keyof typeof TestTypes]

The literal() function is a helper that prompts the compiler to interpret the value as a string literal instead of widening to string.

Now, the value TestTypes.FIRST is exactly the string "first", the value TestTypes.SECOND is exactly the string "second", and the type TestTypes is exactly the union "first"|"second". That lets your class work as desired:

export class Test {
  myType: TestTypes; // this is an annotation, not an initializer, right?

  constructor(options: { type: TestTypes }) {
    // options.type, not type  
    this.myType = options.type;
  }
}

const a = new Test({ type: TestTypes.FIRST }); // okay
const b = new Test({ type: "first" }); // okay... it's the same thing

If you want to keep TestTypes as an enum, you can get what you want, but it's too much hoop jumping in my opinion.

First, if you wanted a standalone function that accepted either the enum or the right string values, you could make a generic function like this:

declare function acceptTestTypesOrString<E extends string>(
  k: E & (Extract<TestTypes, E> extends never ? never : E)
): void;

I don't know if I should explain that, but it takes advantage of the fact that, say, TestTypes.FIRST extends "first". Let's see if it works:

acceptTestTypesOrString(TestTypes.FIRST) // okay
acceptTestTypesOrString(TestTypes.SECOND) // okay
acceptTestTypesOrString("first") // okay
acceptTestTypesOrString("second") // okay
acceptTestTypesOrString("third") // error

Looks good. But you want this as a constructor function. And you can't make a constructor function generic. Instead you could make the whole class generic, like this:

export class Test<E extends string> {
  myType: E; // this is an annotation, not an initializer, right?

  constructor(options: { 
    type: E & (Extract<TestTypes, E> extends never ? never : E) 
  }) {
    // options.type, not type  
    this.myType = options.type;
  }
}

const a = new Test({ type: TestTypes.FIRST }); // okay
const b = new Test({ type: "first" }); // also okay

In this case, a will be of type Test<TestTypes.FIRST> and b will be of type Test<"first">. They are mostly interchangeable, but it seems suboptimal to drag around a generic type for the whole class when you only want it for the constructor.

But it works.


Okay, hope one of those ideas help. Good luck!

Upvotes: 3

Related Questions