why is TypeScript converting string literal union

2020-03-30 03:44发布

问题:

I love string literal union types in TypeScript. I came across a simple case where I was expecting the union type to be retained.

Here is a simple version:

let foo = false;
const bar = foo ? 'foo' : 'bar';

const foobar = {
    bar
}

bar is correctly typed as 'foo' | 'bar':

But foobar.bar gets typed as string:

Just curious why.

Update

So @jcalz and @ggradnig do make good points. But then I realized my use case had an extra twist:

type Status = 'foo' | 'bar' | 'baz';
let foo = false;
const bar: Status = foo ? 'foo' : 'bar';

const foobar = {
    bar
}

Interestingly, bar does have a type of Status. However foobar.bar has a type of 'foo' | 'bar' still.

It seems that the only way to make it behave how I was expecting is to cast 'foo' to Status like:

const bar = foo ? 'foo' as Status : 'bar';

In that case, the typing does work properly. I am OK with that.

回答1:

The compiler uses some heuristics to determine when to widen literals. One of them is the following:

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

So by default, that "foo" | "bar" gets widened to string inside the object literal you've assigned to foobar.

Note the part that says "unless the property has a contextual type that includes literal types." One of the ways to hint to the compiler that a type like "foo" | "bar" should stay narrowed is to have it match a type constrained to string (or a union containing it). The following is a helper function I sometimes use to do this:

type Narrowable = string | number | boolean | symbol | object |
  null | undefined | void | ((...args: any[]) => any) | {};

const literally = <
  T extends V | Array<V | T> | { [k: string]: V | T },
  V extends Narrowable
>(t: T) => t;

The literally() function just returns its argument, but the type tends to be narrower. Yes, it's ugly... I keep it in a utils library out of sight.

Now you can do:

const foobar = literally({
  bar
});

and the type is inferred as { bar: "foo" | "bar" } as you expected.

Whether or not you use something like literally(), I hope this helps you; good luck!



回答2:

That's because let and const are handled differently by TypeScript. Constants are always treated with the "narrower" type - in this case the literals. Variables (and non-readonly object properties) are treated with the "widened" type - string. This is a rule that comes along with literal types.

Now, while your second assignment may be a constant, the property of that constant is in fact mutable - it's a non-readonly property. If you don't provide a "contextual type", the narrow inference gets lost and you get the wider string type.

Here you can read more about literal types. I may quote:

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.

And even more clearly:

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.

By the way, if you provide the contextual type for the constant, the type will be passed on to the variable:

const bar = foo ? 'foo' : 'bar';
let xyz = bar // xyz will be string

const bar: 'foo' | 'bar' = foo ? 'foo' : 'bar';
let xyz = bar // xyz will be 'foo' | 'bar'


回答3:

To answer the updated question with three-value literal union type:

type Status = 'foo' | 'bar' | 'baz';
let foo = false;
const bar: Status = foo ? 'foo' : 'bar';

Declared type of bar is Status, but it's inferred type is still narrowed by control flow analysis to only two possible values out of three, 'foo' | 'bar'.

If you declare another variable without a type, TypeScript will use inferred type for bar, not the declared type:

const zoo = bar; // const zoo: "foo" | "bar"

Without resorting to type assertion as Status, there's no way to turn off type inference based on control flow analysis other than explicitly declaring the type at the place where you need it:

const foobar: {bar: Status} = {
    bar // has Status type now
}