alexanderbird
alexanderbird

Reputation: 4198

How to make TypeScript string enums that work with string literals and do type inference correctly

I want a type to describe a set of strings, and an object with keys for easy access to said strings.

Option 1 (doesn't provide proper type inferencing)

When const TwoWords is initialized with all of the keys' values type-asserted to TwoWords, then DoSomething(TwoWords.Foo) compiles, but the typeguard on the switch statement doesn't work as expected - the type of word in the default case is not never.

type TwoWords = 'foo' | 'bar';
const TwoWords = {
    Foo: 'foo' as TwoWords,
    Bar: 'bar' as TwoWords
};

function DoSomething(word: TwoWords) {
    switch (word) {
        case TwoWords.Foo:
            break;
        case TwoWords.Bar:
            break;
        default:
            let typeInferenceCheck: never = word; // Type '"foo"' is not assignable to type 'never'
    }
}

DoSomething(TwoWords.Foo);
DoSomething('bar');

Option 2 (correct type inference, but overly verbose)

However, if I use a string literal type assertion for each of TwoWords keys' values, the type of word in the default case is never as I would expect.

type TwoWords = 'foo' | 'bar';
const TwoWords = {
    Foo: 'foo' as 'foo',
    Bar: 'bar' as 'bar'
};

function DoSomething(word: TwoWords) {
    switch (word) {
        case TwoWords.Foo:
            break;
        case TwoWords.Bar:
            break;
        default:
            let typeInferenceCheck: never = word; // OK
    }
}

DoSomething(TwoWords.Foo);
DoSomething('bar');

In cases where 'foo' and 'bar' are much longer strings (say, a whole sentence), I don't want to duplicate them - it's too verbose. Is there another way to have a string keyed enum that behaves as expected from a type-inference perspective in a switch statement (or if/else chain)?

Option 3 (not interchangeable with string literals)

As per Madara Uchiha's answer, you can get proper type inference (as in Option 2) without the verbosity using TypeScript 2.4 string enums, but these aren't interchangeable with string literals.

DoSomething('bar'); // Type '"bar"' is not assignable to parameter of type 'TwoWords'

(See GitHub issue #15930 about string literal assignment to TypeScript 2.4 string enum)

Criteria

I am looking for another option which allows me to have:

  1. An enum-style object for accessing string literals EnumLikeObject.Foo === 'foo'
  2. A type indicating that only enum members are allowed, whether they are:
    1. string literals - let thing: EnumLikeObject = 'foo'
    2. properties of the enum-style object - let thing: EnumLikeObject = EnumLikeObject.Foo
  3. the enum-style object and the type must have the same name
  4. In the declaration of the enum-style object and the type, no string literal may be repeated more than twice. If you've got a solution where they must only be repeated once, even better. (In this question, when I speak of verbosity, this criteria is primarily what I'm referring to.)

Objections & Discussion

Upvotes: 7

Views: 2675

Answers (2)

Madara's Ghost
Madara's Ghost

Reputation: 175038

If you care to wait and endure for a bit,

TypeScript 2.4 brings true string enums to the playing field:

enum TwoWords {
  Foo = 'foo',
  Bar = 'bar'
}

function DoSomething(word: TwoWords) {
    switch (word) {
        case TwoWords.Foo:
            break;
        case TwoWords.Bar:
            break;
        default:
            let typeCheck: never = word; // OK
    }
}

That gets you the best of both worlds.

Upvotes: 6

Arlen Beiler
Arlen Beiler

Reputation: 15906

I think I got it. At least:

  • I'm not seeing any red underlines in my code.
  • DoSomething accepts either "foo" or "bar".
  • In the default case, word is void.

And to satisfy your criteria

  1. TwoWords.Foo === 'foo'
  2. The type only allows the literal values specified.
  3. the enum-style object and the type have the same name
  4. The string literals are only written once

And so without further ado:

const TwoWords = (function () {
    const Foo = 'foo';
    const Bar = 'bar';

    const ret = {
        Foo: Foo as typeof Foo,
        Bar: Bar as typeof Bar,
    };
    return ret;
})()
type TwoWords = typeof TwoWords[keyof typeof TwoWords];

And then I had a light bulb moment

namespace TwoWords2 {
    export const Foo = "foo";
    export const Bar = "bar";
}
type TwoWords2 = typeof TwoWords2[keyof typeof TwoWords2]
// didn't test this, not sure if it actually updates the 
// original object or just returns a frozen copy
Object.freeze(TwoWords2); 

It's not a downside, in my opinion, because it still throws an error in the type checker and in VS Code, but TwoWords2.Bar = "five" actually works because the namespace gets compiled to a simple object. But that's the way typescript works. Obviously the first code also has that problem, but it wouldn't throw a type error, so the second is superior, IMO.

Upvotes: 2

Related Questions