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

Narrow type of variable when declared as literal (also tuples) #16896

Open
Nathan-Fenner opened this issue Jul 2, 2017 · 6 comments · May be fixed by Woodpile37/TypeScript#12
Open

Narrow type of variable when declared as literal (also tuples) #16896

Nathan-Fenner opened this issue Jul 2, 2017 · 6 comments · May be fixed by Woodpile37/TypeScript#12
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript

Comments

@Nathan-Fenner
Copy link
Contributor

Nathan-Fenner commented Jul 2, 2017

TypeScript Version: 2.4.1

Code

The following doesn't compile, because x has inferred type string. I think it would be helpful if it did, but I still want x to have inferred type string:

function blah(arg: "foo" | "bar") {
}
let x = "foo";
blah(x);
x = "something else";

Desired behavior:

This would compile.

Actual behavior:

demo.ts(4,6): error TS2345: Argument of type 'string' is not assignable to parameter of type '"foo" | "bar"'.

Suggestion

The type of x should be inferred to be string, but it should be narrowed to "foo" via flow-sensitive typing, much like the below program:

function blah(arg: "foo" | "bar") {
}
let x = "foo";
if (x != "foo") throw "impossible"; // NOTE: codegen would NOT insert this
blah(x);
x = "something else";

Note that this program currently works as desired: x is still inferred to have type string, but the guard immediately narrows it to type "foo". The assignment after the function call widens x's type back to string.

My suggestion is for TS to treat declarations that assign variables to literals (as in the first example) to automatically perform this flow-sensitive type narrowing, without the need for an unnecessary guard.

I'm suggesting it only for declarations, not general assignments (so only with let, var, and const) or other operations. In addition, I'm only suggesting it for assignments to literal values (literal strings or literal numbers) without function calls, operations, or casts.

Syntax Changes

Nothing changes in syntax.

Code Generation

Nothing changes in code generation.

Semantic Changes

Additional flow-sensitive type narrowing occurs when variables are declared as literals, essentially making the existing

let x = "foo";

behave the same as (in checking, but not in codegen)

let x = "foo";
if (x != "foo") throw "unreachable";

Reverse Compatibility

Due to x being assigned a more-precise type than it was in the past, some code is now marked as unreachable/invalid that previously passed:

let x = "foo";
if (x == "bar") { // error TS2365: Operator '==' cannot be applied to types '"foo"' and '"blah"'.
  // ...
}

The error is correct (in that the comparison could never succeed) but it's still possibly undesirable.

I don't know how commonly this occurs in production code for it to be a problem. If it is common, this change could be put behind a flag, or there could be adjustments made to these error cases so that the narrowing doesn't occur (or still narrows, but won't cause complaints from the compiler in these error cases).

If the existing behavior is strongly needed, then the change can be circumvented by using an as cast or an indirection through a function, although these are a little awkward:

let x = "foo" as string;
let x = (() => "foo")()

Tuples and Object Literals

It would be helpful if this extended also to literal objects or literal arrays (as tuples).

For example, the following could compile:

function blah(arg: [number, number]) {
}
let x = [1, 3];
blah(x);
x = [1, 2, 3];

with x again having inferred type number[] but being flow-typed to [number, number]. The same basic considerations apply here as above.

Similarly, it could be useful for objects to have similar flow-typing, although I'm not sure if this introduces new soundness holes:

function get(): number {
    return 0;
}
function blah(arg: {a: "foo" | "bar", b: number}) {
    // (...)
}

let y = get(); // y: number
let x = {a: "foo", b: y}; // x: {a: string, b: number}
// x is narrowed to {a: "foo", b: number}

blah(x); // this compiles due to x's narrowed type

x.a = "something else"; // this is accepted, because x: {a: string, b: number}.

See #16276 and #16360 and probably others for related but different approaches to take here.

@aj-r
Copy link

aj-r commented Jul 4, 2017

Agreed - it would be really nice to make type narrowing work for object literals. Also consider this case:

interface Foo {
   a: string | undefined;
}

function bar() {
    const foo: Foo = { a: "test string" };
    // Currently does not compile, because the compiler thinks a is possibly undefined:
    return foo.a.length;
}

It would be nice if the above code compiled (with strictNullChecks enabled) because a is clearly not undefined.

@mhegazy mhegazy added the Needs Investigation This issue needs a team member to investigate its status. label Aug 29, 2017
@LinusU
Copy link
Contributor

LinusU commented Aug 13, 2018

The more I think about this the more I think it would be highly desirable. I cannot think of any case that would be negatively impacted by making literals be a more narrow type since you can always widen the type. But I can see a lot of positive changes coming from this since you cannot automatically narrow a wide type.

Consider these examples:

declare interface Animal { type: 'cat' | 'dog' }
declare function interactWith (animal: Animal): void

const charlie = { type: 'dog' }

// Argument of { type: string } is not assignable to Animal
interactWith(charlie)

A fix in this simple case would be to add e.g. as Animal to const charlie = ....

type Schema = NullSchema | BooleanSchema | NumberSchema | StringSchema | ArraySchema | ObjectSchema
interface NullSchema { type: 'null' }
interface BooleanSchema { type: 'boolean' }
interface NumberSchema { type: 'number' }
interface StringSchema { type: 'string' }
interface ArraySchema { type: 'array', items: Schema }
interface ObjectSchema { type: 'object', properties: Record<string, Schema> }

declare function validate (schema: Schema, input: unknown): boolean

const schema = {
  type: 'object'
  properties: {
    name: { type: 'string' }
    items: {
      type: 'array',
      items: {
        type: 'object',
        properties: {
          type: { type: 'string' }
        }
      }
    }
  }
}

// Argument of ... is not assignable to Schema
validate(schema, null)

Here the fix is quite annoying, you need to go in and declare the type of every nested schema by making all strings string literals.

const schema = {
  type: 'object' as 'object'
  properties: {
    name: { type: 'string' as 'string' }
    items: {
      type: 'array' as 'array',
      items: {
        type: 'object' as 'object',
        properties: {
          type: { type: 'string' as 'string' }
        }
      }
    }
  }
}

I would love it if we could move forward on this. Is there something that I could contribute with? Do we need to do some kind of research on how this would affect current projects using TypeScript?

@Richiban
Copy link

@LinusU

Here the fix is quite annoying, you need to go in and declare the type of every nested schema by making all strings string literals.

Surely the workaround for this is to simply add a type annotation? const schema : Schema = {, so:

const schema : Schema = {
  type: 'object',
  properties: {
    name: { type: 'string' },
    items: {
      type: 'array',
      items: {
        type: 'object',
        properties: {
          type: { type: 'string' }
        }
      }
    }
  }
}

@LinusU
Copy link
Contributor

LinusU commented Jan 11, 2019

@Richiban, unfortunately, that workaround will throw away any information on what type of scheme it is, so you would no longer be able to do schema.properties.

The logical next step is to declare it as const schema: ObjectSchema = ... then, but that throws away any type information about the properties, so then you can no longer do schema.properties.name...

@RyanCavanaugh RyanCavanaugh added Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript and removed Needs Investigation This issue needs a team member to investigate its status. labels Aug 23, 2019
@millsp
Copy link
Contributor

millsp commented Sep 29, 2019

Proposal

I think that it is important to give full control on type inference. Just a part of the puzzle is missing, because most of the work was done in #29510. So I propose introducing a new keyword as self that works like as const but without making the type deeply immutable.

This would also give full power to the user over generics (not having to explicitly pass them):

const f = <T>(t: T) => t

// default, widening behavior
const test0 = f({a: {b: 'hello'}})
// => {
//     a: {
//         b: string;
//     };
// }

// non-widening, immutable behavior
const test1 = f({a: {b: 'hello'}} as const)
// => {
//     readonly a: {
//         readonly b: 'hello';
//     };
// }

// non-widening, mutable behavior
const test2 = f({a: {b: 'hello'}} as self)
// => {
//     a: {
//         b: 'hello';
//     };
// }

This could give full control on type inference, in general. So we could either use as const, as self, or nothing (as needed).

an alternative to as self could be as is, but is is borrowed by type guards

@JCMais
Copy link

JCMais commented Nov 8, 2019

For Tuples the way I'm doing it currently is by using as const alongside a Writeable helper to remove the readonly modifier, something like this:

const items = [1, 2, 3] as const

function sum3Numbers(a: [number, number, number]) {
    const [n1, n2, n3] = a
    return n1 + n2 + n3
}

// should give an error
sum3Numbers(items)

// workaround:
type Writeable<T> = { -readonly [P in keyof T]: T[P] }
sum3Numbers(items as Writeable<typeof items>)

Playground Link

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript
Projects
None yet
Development

Successfully merging a pull request may close this issue.

8 participants