microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

Home Page:https://www.typescriptlang.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Literal String Union Autocomplete

MrTarantula opened this issue · comments

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

'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

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?

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.

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

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.

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!

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?

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";
    }
}

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

image

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

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

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

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

TypeScript Playground

I have questions

type SuggestingString = "foo" | "bar" | string;
type Hmm<T> = T extends "foo" ? true : false;
// x: false (today)
// x: true | false or false (if this feature exists) ?
type X = Hmm<SuggestingString>;

Should we just do something like this?

/**
 * @suggest "foo", "bar", "baz"
 */
type SuggestingString = string;

Humh... especially when pursuing the goal to have non-contractual property key completion (for example keyof Employee | string) this solution would cause one to end up with something like this:

/**
 * @suggest "PostalCode", "FullName", "FirstName", "LastName"
 */

Unless it's possible to do something like this:

class Employee
{
  // [...]
}

/**
 * @suggest keyof Employee
 */
type SuggestingString = string;

Would that be an option? Is it possible for typescript language-server to treat the content of a tsdoc-tag (in this case suggest) as actual typescript-code?

Should we just do something like this?

For all use cases I've faced so far I wanted to have helpful completion without restricting user from passing an arbitrary value, so your second suggestion would work just fine if it is easier to implement than the original proposal.

Similar to @manuth I would like to not limit this to the fixed list of values, but rather allow to specify an arbitrary subtype of the variable's type and generate completion from this type instead of the main type.

E.g.

type CoreIconName = "user" | "customer";

interface CustomIcons {
  worker: unknown;
}

/**
 * @suggest CoreIconName | keyof CustomIcons
 **/
type IconName = string;

But if this is much harder to implement or has some tricky corner cases, the list of fixed values will be a huge improvement over the current state of things anyways.

One case which comes to mind is how to merge these types:

/**
 * @suggest CoreIconName | keyof CustomIcons
 **/
type IconName = string;
// What should X have in the completion list?
// (CoreIconName | keyof CustomIcons) | string -> nothing?
type X = IconName | string;

But even for the fixed list this may be tricky:

/**
 * @suggest "user", "customer"
 **/
type CoreIconName = string;
/**
 * @suggest "worker"
 **/
type CustomIconName = string;
// What should X have in the completion list?
// Nothing or all of the options?
type X = CoreIconName & CustomIconName;

Maybe @suggest is never merged/inherited is the answer...

Should we just do something like this?

@RyanCavanaugh I would rather this be fixed in the type system. Alternatively, add a built-in type like LiteralUnion. Adding it to doc comments would be my last choice, as it limits a lot possible use-cases, like generating from other types, merging, excluding, etc.

In case there's any interest, a pretty clean-cut use-case for this feature can be found here #38886

@sindresorhus if you want it to be in the type system, it'd be good to provide feedback on how you think it should interact with the type system. That's the conceptual blocker here

I have noticed that that workaround type Values = "foo" | (string & {}) only gives autocompletion when you use Values explicitly, it does not work when you extend Values:

// Gives autocompletion
function test(arg: Values) {}

// Does not give autocompletion
function test<T extends Values>(arg: T) {}

Anyone knows how to get around this?

EDIT

I discovered that you can do this:

// Gives autocompletion
function test<T extends Values>(arg: T | Values) {}

Now you get inferred types and autocompletion

Hi @christianalfoni, can you give an example of autocomplete you're referring to?

For example, if we consider type Values = "foo" | "bar" | (string & {}) and you're given a type T that extends Values, you cannot have autocomplete anymore, because T could be "baz" | "qux", so not getting autocomplete is actually correct in this case.

Maybe you're considering a different situation?

Hi @papb! Maybe autocomplete is the wrong term here. I mean when you hit quotes and VSCode starts suggesting what to enter. Is it autosuggestions? :)

Anyways, in the example:

type Values = 'foo' | 'bar' | (string & {})

function test<T extends Values>(arg: T | Values) {}

When you write:

test('')

VSCode now shows you "foo" and "bar" as suggestions, but it still allows any string 😄

My specific use case is tokens. The user can configure a set of tokens that represents specific values. So a better example would be:

type Color = 'primary' | 'secondary' | (string & {})

function draw<T extends Color>(arg: T | Color) {}

The user has configured a value for "primary" and "secondary", which we want to suggest to the user when they call draw. But they can pass "red", "blue", hex color or whatever they want.

@christianalfoni That's what I meant too... But what is the difference between

function draw<T extends Color>(arg: T | Color) {}

and

function draw(arg: Color) {}

? Aren't they equivalent?

@christianalfoni Hmm, I think something is wrong with your VSCode. I just tested and both methods give the exact same autocompletion, as they should:

type Color = 'primary' | 'secondary' | (string & {});

function draw1<T extends Color>(arg: T | Color) {}

function draw2(arg: Color) {}

draw1('');

draw2('');

image

image

The given LiteralUnion is great for nearly any use case, except one: the union between a list of possible strings, a string, and an object.

For example, I want people to be able to declare a color, or an object describing the color:

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

type COLOR = LiteralUnion<'red' | 'blue'> | { red: number, green: number, blue: number }

Here, trying to assign an object will autocomplete all string methods instead of just red/green/blue.

Expected:
image
image

Actual:
image
image

As you can see, it's giving me the autocompletion on all possible string methods, and also on green/red/blue.
That clutters the autocompletion, and makes such an hack very annoying. Any workaround regarding this problem?

Edit:

I found a solution. First, the problem using U & {} is that any object will be accepted, aka:

type LiteralUnion<T extends U, U = string> = T | (U & {});
type MyObject = LiteralUnion<'hi'> | { test: string }

const myObj: MyObject = { test: 0 }

This snippet will compile, despite test being a number and not a string. Therefore, you should use U & {_?: never}.

Then, to solve the cluttered autocompletion, you can use this:

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

Here, the Pick<U, never> will remove all keys from the string type, but any string will still be accepted. Finally, to prevent people from wondering what this _ property is, the best is a little comment:

export type LiteralUnion<T extends U, U = string> = T | (Pick<U, never> & {
  /**
   * Ignore this property.
   * @deprecated
   */
  _?: never
});

Now, such an example will compile:

type Color = LiteralUnion<'blue'> | {red: number}

//@ts-expect-error
const wrong: Color = { red: 'randomstring' }

const right: Color[] = [{
  red: 255,
}, 'blue', 'randomcolor']

Second edit
Well it's working-ish, but it's not convenient because you have to explicitly cast your types to strings. Example:

- const myString: LiteralUnion<'hey'> = 'hey'
- const myStringBis: string = myString
+ const myString: LiteralUnion<'hey'> = 'hey'
+ const myStringBis: string = myString as string

Needless to say, it's annoying. I tried other stuff but it's not working. An official feature would be neat.

I think that the compiler should keep this metadata, possible separately, to support IntelliSense, similar to the fact that it supports IntelliSense with the following type:

interface T {
  method(): void;
  property: string;
  [p: string]: any;
}

declare let v: T;

v.method(); // Has IntelliSense
console.log(v.property); // Has IntelliSense
console.log(v.anotherProperty); // Still supported

Playground.

I know TypeScript doesn't have opaque types yet, but consider this case:

type HexColor = string;
type AppThemeColors = 'my-app-blue' | 'my-app-red';

type Color = LiteralUnion<HexColor, AppThemeColors>;

There's a way in which I kinda don't want to allow the following:

const myFunc = (color: Color) = { ... }

myFunc('ziltoid') // bad

const oneTimeUseRed: HexColor = '#FF0000'
myFunc(oneTimeUseRed) // good

not sure if --strictFunctionTypes will cover this case, but I hope it would/could.

@dimitropoulos Then you don't need the LiteralUnion hack at all:

type HexColor = `#${string}`;
type AppThemeColors = 'my-app-blue' | 'my-app-red';
type Color = AppThemeColors | HexColor;

const myFunc = (color: Color) => {}

myFunc('ziltoid') // bad
myFunc('my-app-blue') // good
myFunc('#FF0000') // good

That's a great point @frenic. I think I picked a bad example (trying to think through one beyond #29729 (comment)), but consider this augmented example:

type A = string;
type B = 'b1' | 'b2';
type AorB = LiteralUnion<A, B>;

const func = (aorb: AorB) = { ... }

func('ziltoid') // bad

const someA: A = 'some-random-A'
func(someA) // good

I think it is a bit further than just autocomplete. I have this use-case here:

const a = 'a';
const b = 'b';
type Union = typeof a | typeof b | string;

interface SettingA {
  foo: 'bar';
}
interface SettingB {
  bar: 'foo';
}
interface Setting {
  dam: 'bam';
}

type Settings<T = string> = T extends typeof a
  ? SettingA
  : T extends typeof b
  ? SettingB
  : T extends string
  ? Setting 
  : never;

type Ty = { [T in Union]: Settings<T> };
type ValueOf<T> = T[keyof T];
type Ty2 = ValueOf<Ty>;

const array: Array<Ty2> = [];

If Union contains string then array type will be Setting[]
but if I remove string from the Union, then array type is Array<SettingA | SettingB>
My use-case would be that I need the array to be of type Array<SettingA | SettingB | Setting>

@andrei9669 I'm trying to follow your example but I have to be honest I'm getting a little lost. Is it intentional that Setting and SettingB are structurally equivalent? Would it be possible to provide a less abstract use-case?

@andrei9669 I'm trying to follow your example but I have to be honest I'm getting a little lost. Is it intentional that Setting and SettingB are structurally equivalent? Would it be possible to provide a less abstract use-case?

@dimitropoulos oh, sorry, this wasn't my intent, lemme correct that quickly.
If you need better clarification, I can make something up, just let me know.

I found a new way to achieve this, however it results unnecessary boilerplate. It uses generic functions to trick TypeScript and provide autocompletion while accepting any string.

Disclaimer: It only works for functions arguments.

Here is the code:

type AnyString<T, U> = T | U

function recipe<T extends string = ''>(ingredient: AnyString<T, 'carrot' | 'tomato' | 'potato' | 'potatoes'>) {
  /** */
}

As you can see, we defined a T generic on the recipe function, and basically define ingredient to be T | 'carrot' | 'tomato'....

A few remarks:

  • Why using an AnyString type, where using a basic union would work? Because it is displayed when people look at the quick documentation, and it explicitely tells the user he can use any string. If we directly used the union, the user would see '' | 'carrot' | 'tomato' | 'potato' | 'potatoes' because T defaults to '', and could think only those 5 strings are allowed. See the difference below.
    image
    image

  • Why does T defaults to ''? We could make it default to string, or remove the default: the documentation would then show that ingredient can be a string, and that's all. This isn't necessarily a bad thing, it depends if you want your users to see the possible autocompleted strings (or a name like INGREDIENTS) before actually defining the parameter. See the result of defaulting T to string below.
    image

We can define an intermediate type for our possible ingredients:

type AnyString<T, U> = T | U

type INGREDIENTS = 'carrot' | 'tomato' | 'potato' | 'potatoes'

function recipe<T extends string = ''>(ingredient: AnyString<T, INGREDIENTS>) {
  /** */
}

This gives the following documentation:
image

Here is a live GIF that shows this new method in action:
YzH0iSjD6z

So the main argument against having auto-complete on literal unions with a string "just work" was that TypeScript normalizes types early for performance and it is impossible to preserve original type information until later to produce a useful auto-complete for this use case.

From https://devblogs.microsoft.com/typescript/announcing-typescript-4-2-rc/#smarter-type-alias-preservation it sounds like TypeScript is actually fine with preserving some original type information to provide a better developer experience. Was there a change of heart? Is it reasonable to ask TypeScript team to re-consider this use case and implement something similar for literal string unions?

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>);

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

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'
}

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

Any updates on this from the typescript team ?

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

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.

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 :)

commented

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())

@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'
}

@chavyleung @aquaductape Instead of omitting something from itself and creating basically an undefined behaviour, why not try omitting it from a second template type?

For example:

type HintFix<Sub,Super> = Sub | Omit<Super,Sub>;
type Test = HintFix<"a" | "b" | "c", string>;
const testResult = "a"; //Try editing this one!

TLDR simplification of all examples:

type AnyString = string & {}

function testFn(hintedString: 'hi' | 'bye' | AnyString) {}

There are somthing notable when TypeScript version > 4.7.4

  1. The Omit approch does work, but it's downgrading the type and may break some type checks.

image

Whereas string & {} doesn't work when using generic:
image

Finally, an empty interface works perfect for both:
image

And, here's the final perfect Utility type:

interface Nothing {}
type Union<T, U> = T | (U & Nothing)

const str: Union<"foo" | "bar", string> = 'qux'

For example, mixing number and string:
image

Doesn't work for typeguard though

Instead of omitting something from itself and creating basically an undefined behaviour, why not try omitting it from a second template type?

For example:

type HintFix<Sub,Super> = Sub | Omit<Super,Sub>;
type Test = HintFix<"a" | "b" | "c", string>;
const testResult :Test = "a"; //Try editing this one!

@kkmuffme can you try this?

@vaakian Nice, but is there any advantage on what you proposed instead of just using import type {LiteralUnion} from 'type-fest'?

@papb they are literally the same thing. type-fest is using a Record<never, never> instead of an empty Interface.

Just saw this in the iteration plan. This is working in WebStorm (I'm always surprised how much things they developed on top the the default language server).

These Solutions all do not work if you try to use them for a index signature

I am trying to get the same behaviour as part of object, ie kind of const guard, but with no success. But I think my issue is a little bit different, is there a corresponding TS issue?

import { LiteralUnion } from 'type-fest';


type Pet = LiteralUnion<'dog' | 'cat', string>;

type DogHome = {
  citizens: Pet & 'dog',
  canDo: 'wow'
}

type CatHome = {
  citizens: Pet & 'cat',
  canDo: 'meuw'
}

type UnknownHome = {
  citizens: string;
  canDo?: string;
  phone?: string;
}

type AllHomes = DogHome | CatHome | UnknownHome;

const home: AllHomes = {
  citizens: 'cat',
  phone: 'fff' // <- only canDo should be available
}

TS-play

I am trying to get the same behaviour as part of object, ie kind of const guard, but with no success. But I think my issue is a little bit different, is there a corresponding TS issue?

import { LiteralUnion } from 'type-fest';


type Pet = LiteralUnion<'dog' | 'cat', string>;

type DogHome = {
  citizens: Pet & 'dog',
  canDo: 'wow'
}

type CatHome = {
  citizens: Pet & 'cat',
  canDo: 'meuw'
}

type UnknownHome = {
  citizens: string;
  canDo?: string;
  phone?: string;
}

type AllHomes = DogHome | CatHome | UnknownHome;

const home: AllHomes = {
  citizens: 'cat',
  phone: 'fff' // <- only canDo should be available
}

TS-play

I have the similar problem, do you solved this problem?

I cannot believe, what a beautiful 🎁

How is this solved now?

How is this solved now?

no

image
image

It still need add string & {}.

The bulk "Close" action I applied to Design Limitation issues doesn't let you pick what kind of "Close" you're doing, so don't read too much into any issue's particular bit on that. I wish GitHub didn't make this distinction without a way to specify it in many UI actions!

The correct state for Design Limitation issues is closed since we don't have any plausible way of fixing them, and "Open" issues are for representing "work left to be done".