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

Always use literal types #10676

Merged
merged 36 commits into from
Sep 11, 2016
Merged

Always use literal types #10676

merged 36 commits into from
Sep 11, 2016

Conversation

ahejlsberg
Copy link
Member

@ahejlsberg ahejlsberg commented Sep 1, 2016

This PR switches our approach to literal types from being a concept we sometimes use to a concept we consistently use. Previously we'd only use literal types in "literal type locations", which was not particularly intuitive. With this PR we always use literal types for literals and then widen those literal types as appropriate when they are inferred as types for mutable locations.

First some terminology:

  • A literal type is a type that only has a single value, e.g. true, 1, "abc", undefined.
  • String and numeric literal types come in two flavors, widening and non-widening.
  • A literal union type is a union of only literal types.
  • The widened literal type of a type T is:
    • string, if T is a widening string literal type,
    • number, if T is a widening numeric literal type,
    • boolean, if T is true or false,
    • E, if T is a union enum member type E.X,
    • a union of the widened literal types of each constituent, if T is union type, or
    • T itself otherwise.

Note that the widened literal type of any non-primitive type is simply the type itself. For example, the widened literal type of { kind: true } is that type itself, even though the type contains a property with a literal type. Literal type widening is effectively a "shallow" operation.

The following changes are implemented by this PR:

  • The concept of "literal type locations" is gone.
  • The type of a literal in an expression is always a literal type (e.g. true, 1, "abc").
  • The type of a string or numeric literal occurring in an expression is a widening literal type.
  • The type of a string or numeric literal occurring in a type is a non-widening literal type.
  • The type inferred for a const variable or readonly property without a type annotation is the type of the initializer as-is.
  • The type inferred for a let variable, var variable, parameter, or non-readonly property with an initializer and no type annotation is the widened literal type of the initializer.
  • The type inferred for a property in an object literal is the widened literal type of the expression unless the property has a contextual type that includes literal types.
  • The type inferred for an element in an array literal is the widened literal type of the expression unless the element has a contextual type that includes literal types.
  • In a function with no return type annotation and multiple return statements, the inferred return type is a union of the return expression types. Previously the return expressions were required to have a best common supertype, but this is no longer the case.
  • In a function with no return type annotation, if the inferred return type is a literal type (but not a literal union type) and the function does not have a contextual type with a return type that includes literal types, the return type is widened to its widened literal type.
  • During type argument inference for a call expression, the type inferred for a type parameter T is widened to its widened literal type in certain situations as explained below.

The intuitive way to think of these rules is that immutable locations always have the most specific type inferred for them, whereas mutable locations have a widened type inferred. Some examples:

const c1 = 1;  // Type 1
const c2 = c1;  // Type 1
const c3 = "abc";  // Type "abc"
const c4 = true;  // Type true
const c5 = cond ? 1 : "abc";  // Type 1 | "abc"

let v1 = 1;  // Type number
let v2 = c2;  // Type number
let v3 = c3;  // Type string
let v4 = c4;  // Type boolean
let v5 = c5;  // Type number | string
const a = cond ? "foo" : "bar";  // Type "foo" | "bar"
let b = cond ? "foo" : "bar";  // Type string
let c: "foo" | "bar" = cond ? "foo" : "bar";  // Type "foo" | "bar"
const a1 = [1, 2, 3];   // Type number[], because elements are mutable
const a2: [1, 2, 3] = [1, 2, 3];  // Type [1, 2, 3]
const o1 = { kind: 0 };  // Type { kind: number }, because properties are mutable
const o2: { kind: 0 } = { kind: 0 };  // Type { kind: 0 }

Literal type widening can be controlled through explicit type annotations. Specifically, when an expression of a literal type is inferred for a const location without a type annotation, that const variable gets a widening literal type inferred. But when a const location has an explicit literal type annotation, the const variable gets a non-widening literal type.

const c1 = "hello";  // Widening type "hello"
let v1 = c1;  // Type string
const c2 = c1;  // Widening type "hello"
let v2 = c2;  // Type string
const c3: "hello" = "hello";  // Type "hello"
let v3 = c3;  // Type "hello"
const c4: "hello" = c1;  // Type "hello"
let v4 = c4;  // Type "hello"

For further details on widening vs. non-widening string and numeric literals, see #11126.

In a function with no return type annotation, if the inferred return type is a literal type (but not a literal union type) and the function does not have a contextual type with a return type that includes literal types, the return type is widened to its widened literal type:

function foo() {
    return "hello";
}

function bar() {
    return cond ? "foo" : "bar";
}

const c1 = foo();  // string
const c2 = bar();  // "foo" | "bar"

During type argument inference for a call expression the type inferred for a type parameter T is widened to its widened literal type if:

  • all inferences for T were made to top-level occurrences of T within the particular parameter type, and
  • T has no constraint or its constraint does not include primitive or literal types, and
  • T was fixed during inference or T does not occur at top-level in the return type.

An occurrence of a type X within a type S is said to be top-level if S is X or if S is a union or intersection type and X occurs at top-level within S.

In cases where literal types are preserved in inferences for a type parameter (i.e. when no widening takes place), if all inferences are literal types or literal union types with the same base primitive type, the resulting inferred type is a union of the inferences. Otherwise, the inferred type is the common supertype of the inferences, and an error occurs if there is no such common supertype.

Some examples of type inference involving literal types:

declare function f1<T>(x: T): T;
declare function f2<T>(x: T, y: T): T;
declare function f3<T, U>(x: T, y: U): T | U;
declare function f4<T>(x: T): T[];
declare function f5<T extends number>(x: T, y: T): T[];
declare function f6<T>(x: T[]): T;
declare function f7<T>(x: T[]): T[];

const a: (1 | 2)[] = [1, 2];

const x1 = f1(1);  // Type 1
const x2 = f2(1, 2);  // Type 1 | 2
const x3 = f2(1, "two");  // Error
const x4 = f3(1, "two");  // Type 1 | "two"
const x5 = f4(1);  // Type number[]
const x6 = f5(1, 2);  // Type (1 | 2)[]
const x7 = f6([1, 2]);  // Type number
const x8 = f6(a);  // Type 1 | 2
const x9 = f7(a);  // Type (1 | 2)[]

Note that this PR fixes the often complained about issue of requiring a type annotation on a const to preserve a literal type for the const (e.g. const foo: "foo" = "foo") because const now consistently preserves the most specific type for the expression. However, the PR does not provide a way to infer literal types for mutable locations. That is an orthogonal issue we can address in a seperate PR if need be.

NOTE: This description has been updated to reflect the changes introduced by #11126.

@ahejlsberg
Copy link
Member Author

@mhegazy @RyanCavanaugh @DanielRosenwasser I'm sure you'll want to look at this!

@Igorbek
Copy link
Contributor

Igorbek commented Sep 2, 2016

In your example

function foo() {
    return "hello";
}
const c1 = foo();  // string

Why c1 inferred as a string? What would be the type of foo then, () => string ? Why not literal type here? Would it be different if it'd be used as const c2: "hello" = foo(); ?

@ahejlsberg
Copy link
Member Author

ahejlsberg commented Sep 2, 2016

@Igorbek The issue is that you practically never want the literal type. After all, why write a function that promises to always return the same value? Also, if we infer a literal type this common pattern is broken:

class Base {
    getFoo() {
        return 0;  // Default result is 0
    }
}

class Derived extends Base {
    getFoo() {
        // Compute and return a number
    }
}

If we inferred the type 0 for the return type in Base.getFoo, it would be an error to override it with an implementation that actually computes a number. You can of course add a type annotation if you really want to return a literal type, i.e. getFoo(): 0 { return 0; }.

Of course, we could consider having different rules for functions vs. methods and only do the widening in methods, but I think that would be confusing.

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Sep 2, 2016

Should the expression initializing a readonly class property also retain a literal type?

@joewood
Copy link

joewood commented Sep 2, 2016

This is great. I'm wondering if this will address the false positive scenario described by @AlexGalays in #6613 (comment).

This is related to using f-bound polymorphism as a solution to "partial types" (e.g. for Object.assign and React setState). I'm guessing not as parameter binding is treated as a mutable bind and widened.

@ahejlsberg
Copy link
Member Author

Should the expression initializing a readonly class property should also retain a literal type?

Yes, I think that would make sense.

I'm wondering if this will address the false positive scenario described by @AlexGalays in #6613 (comment).

No, the issue in #6613 is unrelated to this change.

@Igorbek
Copy link
Contributor

Igorbek commented Sep 2, 2016

@ahejlsberg thank you, good point. However the same argument can be applied to conditional return as well:

class Base {
    startWithOne = true;
    getFoo() {
        return startWithOne ? 1 : 0; // now it will be typed as 1 | 0 ?
    }
}

class Derived extends Base {
    getFoo() {
        // Compute and return a number
    }
}

Technically speaking, class methods cannot be considered immutable locations. And even plain functions cannot be:

function foo() { return cond ? 1 : 2; }
foo = function() { return 3; } // error?
const foo2 = function() { return 1; } // now it's really immutable

@Igorbek
Copy link
Contributor

Igorbek commented Sep 2, 2016

I would say that function declaration

function foo() { return 1; }

is equivalent (with hoisting in mind) to

let foo = function () { return 1; };

where function () { return 1; } is an expression (immutable location) of type () => 1 and foo is a mutable location so that it must be of base type () => number.

function () { return 1; }; // () => 1
let foo = function () { return 1; }; // () => number
function foo() { return 1; }; // () => number
const boo = function () { return 1; }; // () => 1

@alitaheri
Copy link

This is really great ❤️ ❤️

Just a small detail:

The issue is that you practically never want the literal type. After all, why write a function that promises to always return the same value?

How about a function from which we want to return a finite set of literal types:

function foo(n: 1 | 2 | 3) {
  switch (n) {
    case 1: return 'one';
    case 2: return 'two';
    case 3: return 'three';
    default: return 'none';
  }
}

Although it's a better practice to explicitly type the return value, but this might a good example of inferring the return type as a literal.

To avoid it, the developer can simply annotate the return type as string. But to make it return literal union another interface will need to be declared and maintained with the function body.

@ahejlsberg
Copy link
Member Author

@alitaheri The function in your example has a literal union type inferred as its return type:

function foo(n: 1 | 2 | 3): "one" | "two" | "three" | "none";

Widening occurs only if the inferred return type is a singleton literal type.

@ahejlsberg
Copy link
Member Author

@Igorbek In the case of return cond ? 0 : 1 the inferred return type would be 0 | 1. I think in this case it is not at all clear we should widen to the base primitive type. After all, with a return type of 0 | 1 there is actually meaningful information being conveyed out of the function. It's like the difference between a function that always returns false vs. a function that returns true | false.

@ahejlsberg
Copy link
Member Author

I have updated the introduction to include the changes in the latest commits, plus I have added some more details and examples.

@masaeedu
Copy link
Contributor

Is there some kind of readonly array type I can use or write myself which will inform TypeScript that the array I am creating isn't allowed to be modified? Alternatively, is there some other trick I can use to get ["foo", "bar", ... to infer as ["foo", "bar", ...]?

@vituchon
Copy link

i am trying to understand this matter here, but i got some problem with terminology:

quoting https://github.com/ahejlsberg

The widened literal type of a type T is:
string, if T is a widening string literal type,
number, if T is a widening numeric literal type,
boolean, if T is true or false,

But true or false are types itselfves? i don't get it... sorry if i'm annoying.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Breaking Change Would introduce errors in existing code Domain: Literal Types Unit types including string literal types, numeric literal types, Boolean literals, null, undefined
Projects
None yet
Development

Successfully merging this pull request may close these issues.