Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Literal String Union Autocomplete #29729

Open
MrTarantula opened this issue Feb 4, 2019 · 49 comments
Open

Literal String Union Autocomplete #29729

MrTarantula opened this issue Feb 4, 2019 · 49 comments
Labels
Design Limitation

Comments

@MrTarantula
Copy link

@MrTarantula MrTarantula commented Feb 4, 2019

Autocomplete works for literal string unions, but adding a union of string negates autocomplete entirely. This has been brought up before but I believe there is enough value in this feature to be reconsidered.

My use case is to have a union of string literals for several colors, but also allow hex codes without having to add 16.7 million string literals.

TypeScript Version: 3.4.0-dev.20190202

Search Terms: Literal string union autocomplete

Code

interface Options {
  borderColor: 'black' | 'red' | 'green' | 'yellow' | 'blue' | string
};

const opts: Options = {borderColor: 'red'};

Expected behavior:

image

Actual behavior:

image

Playground Link: https://stackblitz.com/edit/typescript-bwyyab

Related Issues: #12687 #13614

@RyanCavanaugh RyanCavanaugh added the Design Limitation label Feb 4, 2019
@RyanCavanaugh
Copy link
Member

@RyanCavanaugh RyanCavanaugh commented Feb 4, 2019

'black' | 'red' | 'green' | 'yellow' | 'blue' | string

From the compiler's point of view, this is just a very fancy way of writing string. By the time we're looking for suggestions at borderColor, all the literals are lost.

You could write something like this:
image

Naturally this doesn't stop you from writing "bluck". You might want to track #6579

@sindresorhus
Copy link

@sindresorhus sindresorhus commented Feb 4, 2019

By the time we're looking for suggestions at borderColor, all the literals are lost.

Why not improve the compiler to keep more metadata around?

@MrTarantula
Copy link
Author

@MrTarantula MrTarantula commented Feb 4, 2019

You could write something like this:

It may accomplish the same behavior, but that's not intuitive to me at all. I doubt I could have gotten there on my own, and I know I would have trouble explaining it to someone newly coming to TS from JS.

@spcfran
Copy link

@spcfran spcfran commented Mar 11, 2019

It would be great to have this built in, although understand it may be difficult to implement on the compiler.

In the meantime, a generic workaround based on @RyanCavanaugh's solution might help:

type LiteralUnion<T extends U, U = string> = T | (U & { zz_IGNORE_ME?: never })

type Color = LiteralUnion<'red' | 'black'>

var c: Color = 'red'                    // Has intellisense
var d: Color = 'any-string'             // Any string is OK
var d: Color = { zz_IGNORE_ME: '' }     // { zz_IGNORE_ME } placeholder is at the bottom of intellisense list and errors because of never 

type N = LiteralUnion<1 | 2, number> // Works with numbers too

See it in action here

@BendingBender
Copy link

@BendingBender BendingBender commented Mar 12, 2019

Would be great if this could be implemented. It has the potential to improve even core Node.js APIs. The hash.digest encondig param, for example should accept plain strings but there are values available on all platforms that could be enumerated for ease of use.

@manuth
Copy link
Contributor

@manuth manuth commented Jun 26, 2019

Hey Guys
Though the issue still isn't solved but I found out that in TS 3.5.1 you don't even have to create some weird property in order to get the workaround done:

type LiteralUnion<T extends U, U = string> = T | (U & {});
let x: LiteralUnion<"hello" | "world">;
x = "hey";

While this code is perfectly valid, "hello", and "world" are still showing up in the autocompletion.
Thank you for this great fix, @RyanCavanaugh and @spcfran!

@frenic
Copy link

@frenic frenic commented Nov 16, 2019

I noticed a problem with type guards using this hack:

type LiteralUnion<T extends U, U = string> = T | (U & {});

function something(arg: LiteralUnion<'a' | 'b'>): 'a' {
  if (arg === 'a') {
    return arg; // Type '(string & {}) | "a"' is not assignable to type '"a"'
  }
}

Is there a way around this?

@manuth
Copy link
Contributor

@manuth manuth commented Nov 18, 2019

I think I might have found a solution:

Use a type like my UnpackedLiteralUnion type to unpack the actual type of the LiteralUnion:

type LiteralUnion<T extends U, U = string> = T | (U & {});
type UnpackedLiteralUnion<T> = T extends LiteralUnion<any, infer U> ? U : never

function something(arg: LiteralUnion<'a' | 'b'>): 'a' {
    let unpackedArg = arg as UnpackedLiteralUnion<typeof arg>;

    if (unpackedArg === "a") {
        return unpackedArg;
    }
    else {
        return "a";
    }
}

@AhmedElywa
Copy link

@AhmedElywa AhmedElywa commented Dec 20, 2019

@manuth Your solution not work for me can you know why ?

image

@manuth
Copy link
Contributor

@manuth manuth commented Dec 20, 2019

@AhmedElywa it's because you're using (U & never).
Edit: After a year or something I finally noticed that my very own example was incorrect... sorry, pal 😅😂

Here's the longer explanation:

let x: (string & never); // x has type `never`
let y: number | (string & never); // y has type `number`
let z: ("Hello" | "world") | (string & never); // z has type `"Hello" | "world"`

In order to get the solution to work you have to use U & {}.
Following might give you an idea why this solution works.

How the solution works

let a: ("hello" | "world") | string;

In this snippet a will have type string because both "hello" and "world" inherit string.

let a: ("hello" | "world") | (string & {});

In this code-snippet a will be of type "hello" | "world" | (string & {}) because though both "hello" and "world" inherit string, they do not inherit string & {}, which means "hello" | "world" and string & {} are treated as distinguishable types.

Hope this helped you understanding.

@papb
Copy link

@papb papb commented Mar 22, 2020

For those interested, there is a workaround for this called LiteralUnion in type-fest.

@kotarella1110
Copy link

@kotarella1110 kotarella1110 commented Mar 23, 2020

Is there a way to use this hack with the object key?

スクリーンショット 2020-03-23 10 13 58

TypeScript Playground

@a2br
Copy link

@a2br a2br commented May 5, 2021

Surprised this hasn't been mentioned yet, but to comply with the eslint(@typescript-eslint/ban-types) rule, you can use Record<never, never> instead of {} (which may have unexpected behaviors) when using LiteralUnion:

export type LiteralUnion<T extends U, U = string> = T | (U & Record<never, never>);

@papb
Copy link

@papb papb commented May 24, 2021

Surprised this hasn't been mentioned yet, but to comply with the eslint(@typescript-eslint/ban-types) rule, you can use Record<never, never> instead of {} (which may have unexpected behaviors) when using LiteralUnion:

export type LiteralUnion<T extends U, U = string> = T | (U & Record<never, never>);

@a2br This is very cool, never thought of using Record<never, never> before. Can you clarify what you mean by "which may have unexpected behaviors"?

@a2br
Copy link

@a2br a2br commented May 24, 2021

@a2br This is very cool, never thought of using Record<never, never> before. Can you clarify what you mean by "which may have unexpected behaviors"?

Using {} is counter-intuitive, as it not only means 'empty object', but also 'any non-nullish value'. Here's a link to the ESLint rule.

louy2 added a commit to louy2/helmet that referenced this issue Jul 6, 2021
Changes the XFrameOptionsOptions to let typescript suggest "DENY" and
"SAMEORIGIN" in autocomplete with the trick in
microsoft/TypeScript#29729
EvanHahn pushed a commit to helmetjs/helmet that referenced this issue Aug 18, 2021
Changes the XFrameOptionsOptions to let typescript suggest "DENY" and
"SAMEORIGIN" in autocomplete with the trick in
microsoft/TypeScript#29729
@aquaductape
Copy link

@aquaductape aquaductape commented Oct 25, 2021

For those interested, there is a workaround for this called LiteralUnion in type-fest.

Doesn't work with type guard situation

function something(arg: LiteralUnion<'a'| 'c', string>): 'a' {
  if (arg === 'a') {
    return arg; // Type '"a" | (string & { _?: undefined; })' is not assignable to type '"a"'.

  }
  return 'a'
}

@matthewrobertson
Copy link

@matthewrobertson matthewrobertson commented Nov 9, 2021

This is similar to @aquaductape's observation that the current work around doesn't work with type guards, but I was hoping to build an API that leveraged discriminated unions to offer autocompletion for known types but also supported unknown types. Something like this:

interface Circle {
    kind: 'circle';
    radius: number;
};

interface Square {
    kind: 'square';
    width: number;
};

interface UnknownShape {
    kind: string & {zzIngore: any};
    getArea(): number;
}

type Shape = Circle | Square | UnknownShape;

function area(shape: Shape): number {
    if (shape.kind === 'square') {
        // Property 'width' does not exist on type 'Square | GenericShape'.
        //  Property 'width' does not exist on type 'GenericShape'.(2339)
        return shape.width * shape.width; 
    }
    if (shape.kind === 'circle') {
        return Math.PI * shape.radius * shape.radius;
    }
    return shape.getArea();
}

TS Playground

@ShivamJoker
Copy link

@ShivamJoker ShivamJoker commented Nov 15, 2021

Any updates on this from the typescript team ?

@NemoStein
Copy link

@NemoStein NemoStein commented Nov 18, 2021

AFAIK, none of the proposed workarounds work in javascript (with jsdoc).
It's not uncommon to have a list of default values but allow custom ones.

The closest I got from making this work was with template literal types, but that forces me to use a pattern

// Note the ! in the template string
/** @type {'default' | `!${string}`} */
let value = 'default' // Autocompleted in vscode as exected
value = '!allowed' // TS allows
value = 'nop' // TS errors here

// Doesn't work without
/** @type {'default' | `${string}`} */
let value = '|' // No autocomplete here

@antdking
Copy link

@antdking antdking commented Mar 8, 2022

AFAIK, none of the proposed workarounds work in javascript (with jsdoc). It's not uncommon to have a list of default values but allow custom ones.

Seems to work (TS 4.6.2).

// @ts-check

/** @type {'default' | string & {}} */
let value = "default"; // autocompletes

Must have just been getting hung up on it being wrapped in a template literal.

@johot
Copy link

@johot johot commented Mar 29, 2022

The easiest way that I have learned is to simply omit the constant string values from string itself like so:

interface Options {
  borderColor:
    | 'black'
    | 'red'
    | 'green'
    | 'yellow'
    | 'blue'
    | Omit<string, 'black' | 'red' | 'green' | 'yellow' | 'blue'>
}

const opts: Options = { borderColor: 'red' }

Now you get autocomplete but can still set borderColor to arbitrary strings :)

@chavyleung
Copy link

@chavyleung chavyleung commented Mar 30, 2022

The easiest way that I have learned is to simply omit the constant string values from string itself like so:

interface Options {
  borderColor:
    | 'black'
    | 'red'
    | 'green'
    | 'yellow'
    | 'blue'
    | Omit<string, 'black' | 'red' | 'green' | 'yellow' | 'blue'>
}

const opts: Options = { borderColor: 'red' }

Now you get autocomplete but can still set borderColor to arbitrary strings :)

Based on yours

type LiteralUnion<T extends string | number> = T | Omit<T, T>;

let str: LiteralUnion<'abc' | 'cba'>
str = 'abc'
str = 'abcc'
console.log(str.valueOf())

let num: LiteralUnion<123 | 321>
num = 123
num = 1.23
console.log(num.valueOf())

let mix: LiteralUnion<123 | 'abc'>
mix = 'abc'
mix = 123
console.log(mix.valueOf())

@aquaductape
Copy link

@aquaductape aquaductape commented Mar 30, 2022

@chavyleung Unfortunately doesn't work for type guards #29729 (comment)

type LiteralUnion<T extends string | number> = T | Omit<T, T>;

function something(arg: LiteralUnion<'a'| 'c'>): 'a' {
  if (arg === 'a') {
    return arg; // Type '"a" | Omit<"a" | "c", "a" | "c">' is not assignable to type '"a"'.  Type 'Omit<"a" | "c", "a" | "c">' is not assignable to type '"a"'.ts(2322)

  }
  return 'a'
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Design Limitation
Projects
Development

No branches or pull requests