Typescript: Literal types don't work with generics as expected

Created on 15 Nov 2016  ·  12Comments  ·  Source: microsoft/TypeScript

TypeScript Version: master (cef9d8597914f633f51c15b5ae6f9691c4dcfbc5)

Code

function create<T, D>(type: T, data: D) {
  return { type, data };
}

const obj = create('x', 1);

Expected behavior:
obj has type { type: 'x', data: 1 }

Actual behavior:
obj has type { type: string, data: number }

Working as Intended

Most helpful comment

@aluanhaddad If you know what the domain of each type argument is supposed to be, specifying it like so will also cause literal types to be correctly inferred from literals:

function create<T extends string, D extends number>(type: T, data: D) {
  return { type, data };
}

const x = create('x', 2); // { type: "x", data: 2 };

It's only unconstrained generics which have the widening ambiguity issue.

All 12 comments

const obj = create<'x', number>('x', 1) is what you're looking for. Typescript assumes you just intended for 'x' to be a string, not a literal. So if you want it to be typed with a literal, you have to explicitly mark that during your function call.

@rozzzly
It likes old (TypeScript 2.0) literal types behaviour:

const x1 = 1;      // x1: number
const x2: 1 = 1;  // x2: 1

But in TypeScript 2.1 literal types are much cleaner and powerful:

const x = 1; // x: 1

I expect similar behaviour for generics (like in issue description).

Unless there is _some_ indication that you want the literal type preserved, TypeScript will widen the type when it is inferred for a mutable location (such as an object literal property). There are many ways you can indicate you want to preserve the literal type, including:

const X: 'x' = 'x';
const ONE: 1 = 1;
const obj1 = create<'x', 1>('x', 1);  // { type: 'x', data: 1 }
const obj2 = create('x' as 'x', 1 as 1);  // { type: 'x', data: 1 }
const obj3 = create(X, ONE);  // { type: 'x', data: 1 }

For more discussion see #11126.

I'm slightly confused.

In the following

const X: 'x' = 'x'; // "x"

const o = create(X, 1);  // { type: "x", data: number };

the type of o.type is inferred as "x" via the type of X.

In the following, however,

const X = 'x'; // still "x"

const o = create(X, 1);  // { type: string, data: number };

the type of o.type is inferred as string seemingly because X is not annotated. This puzzles me because the type of X is exactly the same in both cases.

@aluanhaddad The difference between a const with and without a type annotation is explained in detail in #11126.

@aluanhaddad I should add that the intuitive way to think of this is that we will never widen a literal type that resulted from an _explicit_ type annotation. We only widen _implicit_ literal types.

@ahejlsberg excellent. Thank you.

@ahejlsberg I appreciate the clarification. It definitely makes sense to me now but on the whole this is an interesting and rather subtle behavior.
In the past (prior to this discussion) I would have flagged the explicit type annotation, say during a code review, as unnecessary. Obviously this is a matter of style and opinion but it is interesting that the annotation has this kind of second-order effect.

@aluanhaddad If you know what the domain of each type argument is supposed to be, specifying it like so will also cause literal types to be correctly inferred from literals:

function create<T extends string, D extends number>(type: T, data: D) {
  return { type, data };
}

const x = create('x', 2); // { type: "x", data: 2 };

It's only unconstrained generics which have the widening ambiguity issue.

@weswigham thank you for that. I like how the use of constraints makes the code more clear and at the same time appropriately places the authority of determining the literalness of the return type with the callee.

@weswigham
Thank you for this generics example. Is this code just a hack or a bug or a normal code/behaviour, that I can use for now and future?

@ahejlsberg
Thank you and all team for literal types. But after widening/non-widening (#11126) I need to write more code with more types in common cases. Example:

const x: 1 = 1; // because I want use x as 1, for example, in object literals { prop: x }

const y = x; // y: 1
let z = x; // z: 1

In this example const y and let z have absolutely the same behaviour. It's very strange for me. I need to write let smth: number everywhere. Because if I don't want mutable behaviour I will simply write const smth.

@weswigham Wonderful! I found your example can be extended to take arbitary literal types 👍

type TypesCanBeLiteral = number | string | boolean;

function create<T extends TypesCanBeLiteral, D extends TypesCanBeLiteral>(type: T, data: D) {
  return { type, data };
}

const x = create('x', 2); //: { type: "x", data: 2 };
const y = create(1, true); //: { type: 1, data: true }
Was this page helpful?
0 / 5 - 0 ratings