Skip to content

Type literal assertion as const to prevent type widening #26979

Description

@cshaa

Search Terms

String literal type and number literal type in an object literal. Type assertion to make the string literal or number literal also a type literal. Cast string or number to type literal. Make a property definition in object literal behave like constant for the type inference. Define a property as string literal or number literal in an object. Narow the type of a literal to its exact value.

Background

When I declare a constant and assign a string literal or a number literal to it, type inference works differently than when I declare a variable in the same way. While the variable assumes a wide type, the constant settles for a much narrower type literal.

let   a = 'foo'; // type of a is string
const b = 'foo'; // type of b is 'foo'

This behavior comes in handy in many scenarios when exact values are needed. The problem is that the compiler cannot be forced to the "type literal inference mode" outside of the const declaration. For example the only way of defining an object literal with type-literal properties is this:

const o = {
  a: 42 as 42,
  b: 'foo' as 'foo',
  c: 'someReallyLongPropertyValue' as 'someReallyLongPropertyValue',
  d: Symbol('a') as... ahem, what?
};
// type, as well as value, of o is { a: 42, b: 'foo', c: 'some…' }

That is not only incredibly annoying, but also violates the principles of DRY code. In adition it's really annoying.

Suggestion

Since const is already a reserved keyword and can't be used as a name for a type, adding a type assertion expresion as const would cause no harm. This expression would switch the compiler to the "constant inference mode" where it prefers type literals over wide types.

// In objects
const o = {
  a: 42 as const, // type: 42
  b: 'foo' as const, // type: 'foo'
  c: 'someReallyLongPropertyValue' as const
  d: Symbol('a') as const // type: typeof o.d
}


// In generic function
let f = <T>(a: T) => ({ boxed: a });
let x = f(42 as const) // T is 42, typeof x is { boxed: 42 }

Non-simple types

There are currently three competing proposals for the way as const would work with non-simple types.

Shallow literal assertion

The first one would stay true to its name and would treat the type as if it were assigned to a constant.

// Type of a is exactly the same as the type of b, regardless of X
const a = X;
let b = X as const;

However, this would mean that all non-trivial expressions would stay the same as they were without the as const. Therefore I would argue it's less useful than the other two proposals.

Tuple-friendly literal assertion

The second proposal differs in the way it treats array literals. While the shallow assertion treats all array literals as arrays, the tuple-friendly assertion treats them as tuples. Then it recursively propagates deeper untill it stops at a type that is neither a string, number, boolean, symbol, nor Array.

let a = [1, 2] as const; // type: [1, 2]
let b = [ [1, 2], 'foo' ] as const; // type: [ [1, 2], 'foo' ]
let c = [ 1, { a: 1 } ] as const; // type: [ 1, { a: number } ]

// Furthermore new syntax could be invented for arrays of const
// but that is outside the scope of this proposal right now
let d = [ [1,2] as const, [3,4] as const, [5,6] as const ]; // type: Array< [1,2] | [3,4] | [5,6] >
let e = [ [1,2], [3,4], [5,6] ] as const[]; // type: Array< [1,2] | [3,4] | [5,6] >

This is probably the most useful proposal, as it solves both the problem described in Use Cases and the problems described in #11152.

Deep literal assertion

The third proposal would recursively iterate even through object literals. I included it just for sake of completeness, but I don't think it could be any more useful than the tuple-friendly variant.

let c = [ 1, { a: 1, b: [1, 2] } ] as const; // type: [ 1, { a: 1, b: [1, 2] } ]

Use Cases

Say I'm using a library which takes a very long object of type LibraryParams of various parameters and input data. I don't know some of the data right away, I need to compute them in my program, so I'd like to create my object myParams which I would fill and then pass to the library.

Since some of the properties of LibraryParams are optional and I don't want to check for them or assert them every time I use them – I know I've set them, right? – I wouldn't set the type of myParams to LibraryParams. Rather I'd use a narrower type by simply declaring an object literal with the things I need.

However some of the properties need to be selected from a set of exact values and when I add them to myParams, they turn into a string or a number and render my object incompatible with LibraryParams.

There are some ways around it using the existing code, none of which are particularly good. I'll give some examples in the next section.

Examples

Imagine that all of these examples contain much longer programs.

// Type is too wide because of LibraryParams

const myParams: LibraryParams = {
  name: "Foobar",
  favouritePrime: 7,
  sports: [ 'chess' ]
}

if (myFunctions.lovesFootball()) {
  myParams.sports.push('football'); //sports is possibly undefined 🤷
}

Library.doThings(myParams);
// Type is too wide because of literal type widening

const myParams = {
  name: "Foobar",
  favouritePrime: 7,
  sports: [ 'chess' ]
}

if (myFunctions.lovesFootball()) {
  myParams.sports.push('football');
}

Library.doThings(myParams); //number is not assignable to prime 🤷
// Too many type assertions

const myParams = {
  name: "Foobar",
  favouritePrime: 7,
  sports: [ 'chess' ]
}

if (myFunctions.lovesFootball()) {
   // this looks even weirder when you do it for the 10th time 🤮
  (myParams.sports as string[]).push('football');
}

Library.doThings(myParams);
// Too much searching for the correct types

const myParams = {
  name: "Foobar",
  favouritePrime: Library.ParamTypes.Primes.Seven, // 🤮
  sports: [ 'chess' ]
}

if (myFunctions.lovesFootball()) {
  myParams.sports.push('football');
}

Library.doThings(myParams);
// Probably the best solution but definitely not very dry

const myParams = {
  name: "Foobar",
  favouritePrime: 7 as 7 // 😕
  sports: [ 'chess' ]
}

if (myFunctions.lovesFootball()) {
  myParams.sports.push('football');
}

Library.doThings(myParams);
// Top tier 👌😉

const myParams = {
  name: "Foobar",
  favouritePrime: 7 as const // 🤩
  sports: [ 'chess' ]
}

if (myFunctions.lovesFootball()) {
  myParams.sports.push('football');
}

Library.doThings(myParams);

Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript / JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. new expression-level syntax)

Related

#14745 Bug that allows number literal types to be incremented
#20195 A different approach to the problem of type literals in objects, excluding generic functions
#11152 Use cases for as const in generic functions

Activity

  1. mattmccutchen commented on Sep 8, 2018

    @mattmccutchen
    Contributor

    I think this is a duplicate of #10195.

  2. cshaa commented on Sep 8, 2018

    @cshaa
    Author

    Matt McCutchen (@mattmccutchen) You're right, #10195 proposes a different solution for the same problem. However I think this one could be implemented much more easily as it doesn't have any breaking changes and doesn't colide with something as basic as... parentheses.

  3. changed the title [-]Type literal assertion `as const` to solve pain in object literal definition[/-] [+]Type literal assertion `as const` to prevent type widening[/+] on Sep 8, 2018
  4. zpdDG4gta8XKpMCd commented on Sep 8, 2018

    @zpdDG4gta8XKpMCd

    "solution" that works today:

    const data = new class {
      readonly name = "Foobar";
      readonly favouritePrime = 7;
      readonly sports = [ 'chess' ];
    };
  5. yortus commented on Sep 9, 2018

    @yortus
    Contributor

    I suggested pretty much the same thing in #10195 (comment), but with as unit instead of as const. Daniel Rosenwasser (@DanielRosenwasser) responded with some feedback in #10195 (comment).

  6. connorjclark commented on Sep 13, 2018

    @connorjclark
    Contributor

    One example of how this comes up in common code (if I am understanding the issue correctly) - React CSS styles.

    const style = {
      textAlign: 'center' as 'center'
    }
    
    return <div style={style}></div>
    
  7. denis-sokolov commented on Sep 13, 2018

    @denis-sokolov

    The React CSS styles can be handled with a type annotation on the constant:

    const style: React.CSSProperties = {
      textAlign: 'center'
    }
    
    return <div style={style}></div>
  8. gwicksted commented on Sep 25, 2018

    @gwicksted
    Contributor

    This is somewhat possible with the following notation:

    // doesn't quite work right for interface types:
    type Exactly<T> = T | never;
    
    // for value types:
    // unfortunately you have to echo v into the type param if you want to constrain it to only that value
    const Literally = <T>(v: T): Exactly<T> => {
        return v as Readonly<T>;
    };
    // In objects
    const o = {
      a: Literally<42>(42),
      b: Literally<"foo">('foo'),
      c: Literally<'someReallyLongPropertyValue'>('someReallyLongPropertyValue'),
      d: Literally(Symbol('a'))
    };
    
    // In generic function
    let f = <T>(a: T) => ({ boxed: a });
    let x = f(Literally<42>(42));
    
    x.boxed = 43; // compile time error

    Example in Playground

  9. yortus commented on Sep 25, 2018

    @yortus
    Contributor

    Graeme Wicksted (@gwicksted) your code can be simplified as follows (note no need to repeat all the literals twice):

    function literal<T extends string|number|symbol>(v: T): T {
        return v;
    };
    
    // In objects
    const o = {
      a: literal(42),
      b: literal("foo"),
      c: literal('someReallyLongPropertyValue'),
      d: literal(Symbol('a'))
    };
    
    
    // In generic function
    let f = <T>(a: T) => ({ boxed: a });
    let x = f(literal(42));
    
    x.boxed = 43;

    (Playground link)

    But note this only works for string and number literals. Symbols, tuples and booleans are widened (they are widened with your code too).

  10. gwicksted commented on Sep 25, 2018

    @gwicksted
    Contributor

    Troy Gerwien (@yortus) That's great! It seems the trick is the "extends" and one of the literal types with the notable exceptions you mentioned.

    Booleans, Tuples, numbers, and strings all work when given the specific type using as which makes the literal function a shorter option when you don't want to re-type the literal. So other than Symbol, it's possible today to achieve the desired behavior.... just not ideal.

    Full example:

    function literal<T extends string|number>(v: T): T {
        return v;
    };
    
    // In objects
    const o = {
      a: 42 as 42, // or literal(42)
      b: "foo" as "foo", // or literal("foo")
      c: literal("someReallyLongPropertyValue"),
      d: Symbol("a"), // no solution yet
      e: false as false,
      f: ["abc", false] as ["abc", false]
    };
    
    o.a = 43; // error
    o.b = "bar"; // error
    o.c = "shorter"; // error
    o.d = Symbol("d"); // no error
    o.e = true; // error
    o.f = ["def", true]; // error
    
    
    // In generic function
    let f = <T>(a: T) => ({ boxed: a });
    let x = f(literal(42));
    
    x.boxed = 43; // error

    Playground link

    IMO it would be great if something as simple as this would prevent widening in all cases:

    // PROPOSAL: does not work
    function literal<T extends string | number | boolean | symbol>(v: T): T | never {
        return v; // as T | never
    }
  11. cshaa commented on Sep 26, 2018

    @cshaa
    Author

    Graeme Wicksted (@gwicksted) Troy Gerwien (@yortus) This is a cool walkaround, but it affects the compiled JavaScript. However I guess the interpreter will quickly realize it's an identity function and optimize the heck out of it.

  12. yortus commented on Sep 27, 2018

    @yortus
    Contributor

    EDIT: figured out tuples of any length.

    Here's a slightly more refined literal helper function that works for strings, numbers, booleans (as of the 3.2.0-dev.20180916 nightly build) and tuples:

    // Overloaded function - supports strings, numbers, booleans and tuples. Rejects others.
    function literal<T extends string | number | boolean>(t: T): T;
    function literal<T>(t: Tuple<T>): T;
    function literal(t: any) { return t; }
    type Tuple<T> = T extends [any?, ...any[]] ? T : never;
    
    let v1 = literal(42);                           // 42
    let v2 = literal('foo');                        // 'foo'
    let v3 = literal(true);                         // true
    let v4 = literal([]);                           // []
    let v5 = literal([10, 20]);                     // [number, number]
    let v6 = literal([literal(1), false, 'foo']);   // [1, boolean, string]
    let v7 = literal([1, 2, 3, literal('foo'),
                      true, literal(true)]);        // [number, number, number, 'foo', boolean, true]
    let v8 = literal(Symbol('sym'));                // ERROR

    For boolean literals, this depends on #27042 which arrived in the 3.2.0-dev.20180916 nightly.

    For tuples, I couldn't find any general way to prevent them widening. But as a simple workaround, we can just overload the literal helper function for as many tuple elements as deemed practical. the Tuple<T> type seems to do the trick, although I'm not sure why this exact form prevents widening but the simpler T extends [...any[]] does not.

  13. cshaa commented on Sep 27, 2018

    @cshaa
    Author

    Troy Gerwien (@yortus) That's really cool! And when Variadic Generics #5453 land (which is probably still far in the future), this approach could simplify and support even symbols.

  14. gwicksted commented on Sep 27, 2018

    @gwicksted
    Contributor

    Troy Gerwien (@yortus) nice! It's succinct and hits the 99% with a single function. I think it solves #10195 without compiler changes (just lib). Not sure if it helps with #26841

    On that note: should literal(x) remain a copy-and-paste helper until a language feature is added to suppress widening?

    It's unfortunate things become verbose with a complex Tuple structure (but those are rare in my experience):

    const o: {
      f: ["abc", false, [123, true]] as ["abc", false, [123, true]] // this works
      g: literal([literal("abc"), literal(false), literal([literal(123), literal(true)])])
    };
    
    o.f = ["abc", false, [567, false]]; // error on both [567, false]
    o.g= ["abc", false, [567, false]]; // (untested, as I do not have dev nightly at the moment)

    Can it be written like the following and still work? Then any is never present as an argument type.

    type Tuple<T> = T extends [any?, ...any[]] ? T : never;
    function literal<T extends string | number | boolean | Tuple<T>>(t: T): T { return t; }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    In DiscussionNot yet reached consensusSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions