microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.16k stars 12.38k forks source link

Syntax for hinting literal type inference #10195

Closed zpdDG4gta8XKpMCd closed 5 years ago

zpdDG4gta8XKpMCd commented 8 years ago

Now that we have so many literal types we more than ever need new syntax that would make their use natural. Please consider the following:

const value = (true); // true
const value = true; // boolean

const value = ('a'); // 'a'
const value = 'a'; // string

const value = (1); // 1
const value = 1; // number

const value = ['a', 1]; // (string | number)[]
const value = (['a', 1]) // [string, number]
const value = ([('a'), (1)]) // ['a', 1];

Problem:

Solution:

Highlights:

Shortcomings:

Prior work:

zpdDG4gta8XKpMCd commented 8 years ago

i am afraid that @yortus is proposing to piggy-back on the type assertions syntax, which means unit is not even a type, it is just an instruction for the type-checker to consider the following literal being of a literal type

DanielRosenwasser commented 8 years ago

While I like how it reads, it'd be better if the as keyword didn't have two semantically different uses in expression positions.

Artazor commented 8 years ago

Actually I could not find any feasible solution, without violation of the rule "not to play at the expression level". The initial proposal looks the only way but it will hurt a code generation.

About implicit singleton inference I would use not a keyword, but some delimiter like *

var a = <*>123;
var b = 456 as *;

Still strange.

normalser commented 8 years ago
let a1 = <123>*
let a2 = * as 123
let b1 = <123>any
let b2 = any as 123
let c1 = <123>self
let c2 = self as 123
let d1 = <123>unit
let d2 = unit as 123
aluanhaddad commented 8 years ago

Let the syntactic bikeshedding continue. 🚲 🏠.

How about := as in

const one := 1;
yortus commented 8 years ago

it'd be better if the as keyword didn't have two semantically different uses in expression positions.

@DanielRosenwasser can you explain what you mean here? as is already a type assertion operator, so what new meaning is introduced?

var foo = {/***/} as Foo; // type assertion to Foo, otherwise foo would be of type {/***/}
var pi = 3.14 as unit; // type assertion to unit type 3.14, otherwise pi would be of type number

It's still an ordinary type assertion, just a shorthand to avoid having to repeat the literal twice, to avoid things like this:

var CommandID: "'{B96B3CAB-0728-11D3-9D7B-0000F81EF32E}'" = "'{B96B3CAB-0728-11D3-9D7B-0000F81EF32E}'";

...which would still be valid, but could be written shorthard as either of the following:

var CommandID = <unit> "'{B96B3CAB-0728-11D3-9D7B-0000F81EF32E}'";
var CommandID = "'{B96B3CAB-0728-11D3-9D7B-0000F81EF32E}'" as unit;
yortus commented 8 years ago

The unit type is actually a void type.

@Artazor all singleton types are unit types according to https://github.com/Microsoft/TypeScript/pull/9407#issue-162831239 since they have only one value. So 3.14 and "blah" are unit types, and null and undefined are too.

It is not semantically correct to use the same type.

as @aleksey-bykov said unit is not a type, its a shorthard syntax. It could only appear in a type assertion next to a literal value, and would mean 'assert to the unit type of this literal', saving you from writing it twice.

yortus commented 8 years ago

How about :=...

@aluanhaddad that was already suggested, and @aleksey-bykov pointed out it would only work in assignment statements, whereas there are other places it would be useful to assert an expression to a unit type.

Igorbek commented 8 years ago

Allowing unit in type position would introduce a new reserved word that would be conflicting with user-defined type unit. Not a big deal, but need to consider.

yortus commented 8 years ago

@Igorbek that's true. It would be a breaking change to introduce any new keyword in a type position. A symbol could be used for the equivalent thing (already suggested above), it just starts to look like symbol soup var $=<*>"$".

yortus commented 8 years ago

@wallverb your suggestions would require type-driven emit, which is an anti-goal with TypeScript. TypeScript's erasable type system means you can strip out the type annotations and (syntax extensions aside) be left with working JavaScript. Your examples, with type annotations stripped out, would become:

let a1 = *
let a2 = *
let b1 = any
let b2 = any
let c1 = self
let c2 = self
let d1 = unit
let d2 = unit

...which is clearly not correct, hence the need to emit different code based on the type annotations for this to work, which would make the type system non-erasable.

normalser commented 8 years ago

what about crazy idea of:

let a1 = 0u123 // type: 123
let b1 = u'123' // type: '123'

ts could erase it

Just throwing things into the air :)

yortus commented 8 years ago

@wallverb yeah I was thinking of something like that, but the problem is that it adds expression-level syntax (another TypeScript non-goal). It looks like a literal value expression, but it's really an expression plus a type annotation, and it could clash with some future JavaScript syntax.

That's why I though the best thing would be to add syntax only into the type annotation itself, which can't clash with JavaScript. But as @Igorbek pointed out, even that could clash with peoples' existing code. It's a tough one.

yortus commented 8 years ago

This is probably too verbose (but quite readable)... a variation that doesn't add any expression level syntax and can't clash with anyone's existing type names:

var pi = <unit type> 3.14;
var CommandID = "'{B96B3CAB-0728-11D3-9D7B-0000F81EF32E}'" as unit type;

The pi example is actually longer than just writing <3.14> 3.14, but at least it's DRY (so no copypasta problems).

normalser commented 8 years ago

Yea I like your proposal the most

<unit>1
1 as unit
<u>1
1 as u
// or any other short keyword

and we could have another flag for people to opt in :D

Would love to hear @DanielRosenwasser feedback on the as concern

DanielRosenwasser commented 8 years ago

Would love to hear @DanielRosenwasser feedback on the as concern

I'm personally against syntax that looks like it does one thing based on other uses in the language, but does something different. Module augmentations are the best example of this. They take the form of declare module "foo" { } which is identical to the syntax for ambient module declarations, but depending on where they're declared, they mean something different. That is a terrible user experience and was a bad choice of syntax.

Here, you are changing something that originally meant "tell TypeScript the type of this should be unit" to instead do what we're discussing here. I don't think it's quite as bad, but it's not exactly ideal.

normalser commented 8 years ago

Another idea:

Flag: --inferLiteralTypes to opt in

let a = 'test' // Type: 'test'
let b = "test" // Type: string
let c = +5 // Type: 5
let d = 5 // Type: number
let e = +-5 // Type: -5
zakjan commented 8 years ago

Just adding my 2c, I'd welcome such feature for creating Maps:

const map = new Map([
    ["a", 1],
    ["b", 2],
    ["c", 3],
] as [string, number][]);

Without an explicit typecast, TS complains.

DanielRosenwasser commented 8 years ago

Without an explicit typecast, TS complains.

Are you sure?

image

Playground link

yortus commented 8 years ago

I'm personally against syntax that looks like it does one thing based on other uses in the language, but does something different.

@DanielRosenwasser Another example is as any, which looks like a type assertion but actually turns off type checking.

zpdDG4gta8XKpMCd commented 8 years ago

let's leave any alone: #9999

zakjan commented 8 years ago

@DanielRosenwasser This is the error I get:

src/map.ts(1,21): error TS2345: Argument of type '(string | number)[][]' is not assignable to parameter of type 'Iterable<[{}, {}]>'.
  Types of property '[Symbol.iterator]' are incompatible.
    Type '() => IterableIterator<(string | number)[]>' is not assignable to type '() => Iterator<[{}, {}]>'.
      Type 'IterableIterator<(string | number)[]>' is not assignable to type 'Iterator<[{}, {}]>'.
        Types of property 'next' are incompatible.
          Type '(value?: any) => IteratorResult<(string | number)[]>' is not assignable to type '(value?: any) => IteratorResult<[{}, {}]>'.
            Type 'IteratorResult<(string | number)[]>' is not assignable to type 'IteratorResult<[{}, {}]>'.
              Type '(string | number)[]' is not assignable to type '[{}, {}]'.
                Property '0' is missing in type '(string | number)[]'.

I use these typings from CoreJS https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/core-js/core-js.d.ts#L606, there is Iterable<[K, V]> instead of [K, V][]. Why is this causing problems and my explicit typecast of constructor argument fixes the error?

DanielRosenwasser commented 8 years ago

Another example is as any, which looks like a type assertion but actually turns off type checking.

@yortus any is a type, so it's consistent in this. Your earlier proof makes the assumption that a type is only assignable to another if it is a subtype, but that's not how our type system works. See the spec sections on Subtypes and Supertypes and on Assignment Compatibility.

@zakjan Thanks for pointing this out! You actually helped me uncover two issues

As a workaround for your issue, you can use the lib flag in TypeScript 2.0 beta instead of core-js declaration file where this seems to be fixed.

If you're not using TypeScript 2.0 beta, you can add the appropriate overload yourself in a d.ts file for globals.

interface MapConstructor {
    new <K, V>(entries?: [K, V][]): Map<K, V>;
}
aluanhaddad commented 8 years ago

@yortus but as any is a type assertion.

zakjan commented 8 years ago

@DanielRosenwasser Great, thanks a lot. Using lib compiler options helped for me.

yortus commented 8 years ago

Being able to do this would be kinda cool:

function addProp<T, U extends unit string, V>(obj: T, propName: U, propValue: V): T & {U: V} {
    let newObj = clone(obj);
    newObj[propName] = propValue;
    return newObj;
}

let obj1 = {foo: 1, bar: 2};          // obj1 is {foo:number, bar:number}
let obj2 = addProp(obj1, 'baz', 3);   // obj2 is {foo:number, bar:number} & {baz:number}

let obj3 = addProp(obj1, [], 4);      // ERROR: `[]` is not a string literal

Since the U type argument would only accept a literal string, which must therefore be known at compile-time, the compiler could allow you to build statically-known types with ordinary dynamic-looking code.

basarat commented 8 years ago

If we exclude tuples and only take immutable types perhaps no special syntax is needed:

const value = (true); // true
const value = true; // true

const value = ('a'); // 'a'
const value = 'a'; // 'a'

const value = (1); // 1
const value = 1; // 1

refs https://github.com/basarat/typescript-book/issues/165#issuecomment-246293918 /cc @danielearwicker :rose:

zpdDG4gta8XKpMCd commented 8 years ago

until recently it wasnt a case, now indeed literal values in immutable positions are infered as literal type values

however it's still not a case for arguments and return values and a few more situation, so there is still need for syntax

On Sep 12, 2016 7:14 AM, "Basarat Ali Syed" notifications@github.com wrote:

If we exclude tuples and only take immutable types perhaps no special syntax is needed:

const value = (true); // trueconst value = true; // true const value = ('a'); // 'a'const value = 'a'; // 'a' const value = (1); // 1const value = 1; // 1

refs basarat/typescript-book#165 (comment) https://github.com/basarat/typescript-book/issues/165#issuecomment-246293918 /cc @danielearwicker https://github.com/danielearwicker 🌹

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/Microsoft/TypeScript/issues/10195#issuecomment-246317472, or mute the thread https://github.com/notifications/unsubscribe-auth/AA5PzdZDXlMqZy9angd_D0xRJvXOYZTlks5qpTQqgaJpZM4Jei5a .

zpdDG4gta8XKpMCd commented 8 years ago

consider

const a = 2; // 2 let a = 2; // number

On Sep 12, 2016 7:20 AM, "Aleksey Bykov" aleksey.bykov@gmail.com wrote:

until recently it wasnt a case, now indeed literal values in immutable positions are infered as literal type values

however it's still not a case for arguments and return values and a few more situation, so there is still need for syntax

On Sep 12, 2016 7:14 AM, "Basarat Ali Syed" notifications@github.com wrote:

If we exclude tuples and only take immutable types perhaps no special syntax is needed:

const value = (true); // trueconst value = true; // true const value = ('a'); // 'a'const value = 'a'; // 'a' const value = (1); // 1const value = 1; // 1

refs basarat/typescript-book#165 (comment) https://github.com/basarat/typescript-book/issues/165#issuecomment-246293918 /cc @danielearwicker https://github.com/danielearwicker 🌹

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/Microsoft/TypeScript/issues/10195#issuecomment-246317472, or mute the thread https://github.com/notifications/unsubscribe-auth/AA5PzdZDXlMqZy9angd_D0xRJvXOYZTlks5qpTQqgaJpZM4Jei5a .

basarat commented 8 years ago

until recently it wasnt a case, now indeed literal values in immutable

Indeed

image

Awesome! :rose:

zakjan commented 8 years ago

@basarat Please don't exclude tuples from this feature request. They are really useful and it is annoying that it is necessary to always explicitly typecast them from arrays.

function foo(bar: [number, number]): number {
    return bar[0] + bar[1];
}
foo([1,2]); // error
foo([1,2] as [number, number]); // ok
basarat commented 8 years ago

Please don't exclude tuples from this feature request.

Was just trying to get the issue for const foo = "some string" to work. Apparently that work is already done. Happened in these 15 days ¯\_(ツ)_/¯ while I was busy with https://github.com/alm-tools/alm/pull/181

image

So ignore everything I've said :heart:

yortus commented 8 years ago

@basarat it was literally the most recent commit (no pun intended). #10676.

mhegazy commented 8 years ago

thanks @yortus and @basarat. this should indeed be fixed by #10676.

zpdDG4gta8XKpMCd commented 8 years ago

@mhegazy i understand your urge to make progress and have as much items closed as possible, the PR that you mention only addresses a subset of the problem, i would not say it was resolved, would you consider reopening it please or shall i create a new one?

mhegazy commented 8 years ago

i understand your urge to make progress and have as much items closed as possible

Do not think the number of issues i close, or not, affects anything. so no urge here to disregard user comments.

i would not say it was resolved

I do not think we will be adding a new syntax to infer literal types. for the reasons outlined in https://github.com/Microsoft/TypeScript/issues/9217#issuecomment-226551698 I deally the compiler is smart enough to understand what is a "literal type" and what is not. I do believe #10676 has addressed this (with the exception of tuple types).

would you consider reopening

sure thing.

zpdDG4gta8XKpMCd commented 8 years ago

to make it clear, the parts that haven't been addressed:

mhegazy commented 8 years ago

For primitive types, https://github.com/Microsoft/TypeScript/issues/10863 tracks addressing the regression for type assertion (used to work before #10676).

JabX commented 7 years ago

If I may reopen this, now that we have generic defaults, I'd like to give a new exemple on why this feature could be useful:

interface Param<T = "value"> {
    value: T;
}

function test<T = "value">(arg: Param<T> = {value: "value"} as any): Param<T>["value"] {
    return arg.value || "value";
}

const t1 = test(); // Type: "value";
const t2 = test({value: "value"}); // Type: string
const t3 = test({value: "other"}); // Type: string
const t4 = test({value: "otherNameForMyValue" as "otherNameForMyValue"}); // Type: "otherNameForMyValue"

To be clear, the new thing that 2.3 brings is the typing of the t1 variable.

This is not shown in this simple example, but I am using this type as an identifier in a disjointed union, with a default value that is overridable by the user. The problem is that getting a string for the T type is unacceptable since it would break the discriminant property, and the only way to go around it is to use this horrible cast syntax where I have to write my value twice.

I don't think it could be automatically inferred, but at the very least we should have something to make it easy to write.

I've seen a couple of ideas in this thread but none of them were very convincing. Maybe reusing a keyword like let foo = const "value"; or let foo = readonly "value" could work ? Or perhaps could we circumvent the problem altogether and add new types to specify that a generic constraint asks for a literal type instead of a primitive, something like type Param<T extends StringLiteral> = {value: T}, so that the compiler could always infer a literal type there. That does seem like more work than simply add a syntax sugar on the declaration though.

Igorbek commented 7 years ago

if you change function test in your example to function test<T extends string = "value">, it'll capture literal types properly, so that t2 will be "value" and t3 will be "other".

JabX commented 7 years ago

@Igorbek Well that's true in that particular case, but let's take a look at another example that I just encountered:

function select<T, R extends {[P in ValueKey]: T}, ValueKey extends string = "code">(item: T, values: R[], options: {valueKey: ValueKey} = {valueKey: "code"} as any) {
    return values.find(value => value[options.valueKey] === item);
}

select(1, [{code: 1, label: "hello"}]); // Works, ValueKey = "code" which is the default.
select(1, [{id: 1, label: "hello"}], {valueKey: "id"}); // Error, ValueKey = string, understands that values should be {[key: string]: number} and label is a string
select(1, [{id: 1, label: "hello"}], {valueKey: "id" as "id"}); // Works, ValueKey = "id"

Of course, if the compiler could infer the type properly that would be great, but I'm not really sure how possible this is, especially if we care about backward compatibility.

KiaraGrouwstra commented 7 years ago

I hope my PR #17785 would address this, by allowing people to reuse the const vs. let distinction to indicate whether they want [1,2,3] or number[]. There's obviously no silver bullet (what of [number]? (1|2|3)[]?), so there will always be cases where you may need casts. I think where this PR adds value though is by increasing user control.

demurgos commented 6 years ago

I saw some comments proposing syntax that would enable literal inference only for assignation (:=), this is not enough. I type most of my declarations explicitly but I still had issues because of lack of literal type narrowing when I updated one of my libraries to use mapped types. Mapped types improved the "correctness" of the types by removing some anys but require the types of the various objects to be better inferred.

Here is a minimal example exposing the issue:

// Lib part: Provides classes to build schemas and test them at runtime

interface MetaType<T> {
  test(val: any): val is T;
}

// Represents a specific variant from an enum
class EnumLit<T> implements MetaType<T> {
  variant: T;
  constructor(enumVariant: T) {
    this.variant = enumVariant;
  }

  test(val: any): val is T {
    return val === this.variant;
  }
}

// Represents an object with multiple properties, each with their own type
class Doc<T extends {}> implements MetaType<T> {
  props: {[P in keyof T]: MetaType<T[P]>};
  constructor(props: {[P in keyof T]: MetaType<T[P]>}) {
    this.props = props;
  }

  test(val: any): val is T {
    for (const k in this.props) {
      if (!this.props[k].test(val[k])) { return false; }
    }
    return true;
  }
}

// User code

enum AnimalName {
  Duck,
  Cat,
}

interface Duck {
  name: AnimalName.Duck;
}

// This breaks because {name: MetaType<AnimalName>} is not assignable to {name: MetaType<AnimalName.Duck>}
// This worked previously because Doc.params was just `{[P in keyof T]: MetaType<any>}`
const $Duck = new Doc<Duck>({name: new EnumLit(AnimalName.Duck)});

// You have to explicitly state the generic parameter of EnumLit (really heavy due to repetition)
const $Duck2 = new Doc<Duck>({name: new EnumLit<AnimalName.Duck>(AnimalName.Duck)});

Complete error:

error TS2345: Argument of type '{ name: EnumLit<AnimalName>; }' is not assignable to parameter of type '{ name: MetaType<AnimalName.Duck>; }'.
  Types of property 'name' are incompatible.
    Type 'EnumLit<AnimalName>' is not assignable to type 'MetaType<AnimalName.Duck>'.
      Types of property 'test' are incompatible.
        Type '(val: any) => val is AnimalName' is not assignable to type '(val: any) => val is AnimalName.Duck'.
          Type predicate 'val is AnimalName' is not assignable to 'val is AnimalName.Duck'.
            Type 'AnimalName' is not assignable to type 'AnimalName.Duck'.

Regarding the syntax bikeshedding, the idea of parens is nice but I agree that it can be confusing and may break many code generation tools. I'd propose an addition similar to the ! assertion operator: add a unary "literal type narrowing" operator. For example @ or # are unused currently.

Here is a comparison of what the various propositions may look like in my example:

// Parens (not very readable)
const $Duck = new Doc<Duck>({name: new EnumLit((AnimalName.Duck))});

// Diamond
const $Duck = new Doc<Duck>({name: new EnumLit(<> AnimalName.Duck)});

// Unit
const $Duck = new Doc<Duck>({name: new EnumLit(<unit> AnimalName.Duck)});

// Prefix @
const $Duck = new Doc<Duck>({name: new EnumLit(@AnimalName.Duck)});

// Postfix @
const $Duck = new Doc<Duck>({name: new EnumLit(AnimalName.Duck@)});

// Prefix #
const $Duck = new Doc<Duck>({name: new EnumLit(#AnimalName.Duck)});

// Postfix #
const $Duck = new Doc<Duck>({name: new EnumLit(AnimalName.Duck#)});

This operator could also be applied to expressions to ask the compiler to resolve the most specific type. For example @(1 + 2) would be typed as 3.

KiaraGrouwstra commented 6 years ago

@demurgos I'd argue specific types shouldn't require additional effort as type widening is mostly useful under specific circumstances (mutable variable, i.e. var/let assignment), meaning we already have a decent idea what default makes sense when.

For example @(1 + 2) would be typed as 3.

Seems they didn't like this, see #15645.

forivall commented 6 years ago

While it only works for a limited amount of cases, I would suggest overloading the ! postfix operator when used on literal value expressions; since we know that literally 1! would never be nullable, this now means that it's exactly one. I would also think that this has lower impact than the parens idea, since the postfix ! is already a typescript-only syntax, and the only people writing 1! would be doing it as a typo.

So, some examples using postfix ! (filtering out those that have been solved by #10676)

const value = ['a', 1]; // (string | number)[]
const value = ['a', 1]!; // [string, number]
const value = ['a'!, 1!]!; // ['a', 1]
const value = ['a'!, 1!]; // ('a' | 1)[]

const value = {a: 1} // {a: number}
const value = {a: 1!} // {a: 1}

cases that it doesn't solve

const foo = 'foo' // 'foo'
const bar = [foo!]! // would still be [string]
const value = {a: foo!} // still {a: string}

The other syntax solution I can think of (since I like keywords more than characters :P ) is to use as const as a postfix, for example

const value = {a: foo as const}

Or, maybe both? Allow postfix ! to narrow when it's a literal, and as const for more complex mappings?

Edit (2019-01-14): @m93a as submitted this as a separate issue as #26979

zpdDG4gta8XKpMCd commented 6 years ago

good thing is that we can piggyback ride on the existing TypeScript only expression level syntax ! (which is so much at odds with its design goals but who cares right?)

bad part is that there is no way to see what is going on in const value = {a: foo!} without knowing what foo is, it's going to be a nightmare for code reviewers like myself

RyanCavanaugh commented 6 years ago

My interpretation was that ! would only have the literalizing effect on true literal expressions; even ("foo")! should be a no-op IMO. Otherwise you get into a ridiculous situation when expr: "foo" | null - do you then have to write expr!! to prevent it widening?

forivall commented 6 years ago

Yup, exactly what Ryan is saying. The ! would only apply on literals, and so const value = {a: foo!} would unambiguously be a non-null assertion.

falsandtru commented 6 years ago

@forivall I coincidentally opened an issue about that syntax. Could you discuss about that in #22872 if you prefer?

aminpaks commented 5 years ago

This is great, why can't we focus on just string literal type for now?

let value = 'myType'; // string
let value = `myType`; // 'myType'
const value = ['myType']; // string[]
const value = [`myType`]; // 'myType'[]
const value = {a: 'myType'} // {a: string}
const value = {a: `myType`} // {a: 'myType'}

type myType = 'myType'; // OK
type myType = `myType`; // Error
let myType = `myType`; // OK 'myType'