Skip to content

Wrapping type with Readonly causes type alias to be lost in annotation  #34777

Closed

Description

TypeScript Version: 3.7.x-dev.201xxxxx

Search Terms:
Readonly, type, annotation, alias, expand

Code

type Person = {
    name: string;
}

type ReadOnlyPerson = Readonly<{
    name: string;
}>

// hovering on x shows a type annotation of `Person`
let x: Person
// hovering on y shows a type annotation of Readonly<{ name: string }>
let y: ReadOnlyPerson

Expected behavior:
Type aliases should be preserved in annotations when using the Readonly type. In the example above I would expect the type annotation for y to be ReadOnlyPerson

Actual behavior:
Type alias is lost in the annotation when using the Readonly type. The type annotation for y in the example above is Readonly<{ name: string } instead of ReadOnlyPerson

Playground Link:
http://www.typescriptlang.org/play/?ts=3.7-Beta&ssl=10&ssc=22&pln=1&pc=1#code/C4TwDgpgBAChBOBnA9gOygXigbwFBQKlQEMBbCALikWHgEtUBzAblwF9ddRIoAlCYgBMA8qgA2IOEjSY+AwWgkAePISJlK1WgxbsAfJzERgUAB5UpKVLiMmQVfkNETLaIA

Related Issues:
I wasn't able to find any bugs that looked similar, when through a couple of pages of issues using the search terms mentioned above.

Activity

  1. fatcerberus commented on Oct 29, 2019

    @fatcerberus

    IIRC type aliases are not directly preserved in the way you think, TS just remembers if the first instantiation of a type was due to a type alias and back-maps it to the alias if so. Since this is just a heuristic, there are a lot of ways it can go wrong. For further elaboration see #32287 (comment)

    In short: type aliases are not first-class types. They are more like macros where the compiler expands the alias and uses the expansion directly, which often loses information on where the type originated.

  2. cdimitroulas commented on Oct 29, 2019

    @cdimitroulas
    Author

    Thanks for your comment Bruce Pascoe (@fatcerberus) - I understand what you're saying.

    What confuses is me is that the Readonly type doesn't do what I expect in this case. Since the type alias is defined right there, I don't see why the compiler would lose information on where the type originated. I guess what I would expect is for these two to be equivalent:

    type ReadOnlyPerson = Readonly<{
        name: string;
    }>
    
    type ReadOnlyInLinePerson = {
        readonly name: string;
    }
    
    let y: ReadOnlyPerson
    // hovering on z gives the expected type annotation -> z: ReadOnlyInLinePerson
    let z: ReadOnlyInLinePerson

    Edit: I guess the workaround would be to not use the Readonly type and just write readonly on every line manually instead and it would have the same effect, but it would be nice to avoid that if possible :)

  3. fatcerberus commented on Oct 29, 2019

    @fatcerberus

    It confused me at first, too, but here's what I think happens:

    1. You declare x as Person. This forces the Person type alias to be evaluated, which produces the concrete type { name: string }.
    2. From here you're good: TS remembers that { name: string } was first constructed via the type expression Person, and displays that in IntelliSense whenever it encounters that same type in the future.
    3. You declare y as ReadOnlyPerson. TS evaluates ReadOnlyPerson and gets Readonly<{ name: string }>. This is not yet a first-class type as it contains the Readonly type alias, so must be further evaluated.
    4. TS evaluates Readonly<{ name: string }> and gets { readonly name: string }.
    5. This is a concrete type, so TS remembers that it was produced by the type expression Readonly<{ name: string }> and displays that in IntelliSense whenever it encounters the same type in the future.
    6. Because there was a second type alias in the way, information about the first one--ReadOnlyPerson--has been lost.

    I do wonder why it doesn't display as Readonly<Person>, though. This is just speculation but I'm thinking TS remembers the exact source text of the type expression that created the type, rather than storing the type parameters individually. You can see this by declaring ReadOnlyPerson as:

    type ReadOnlyPerson = Readonly<Person>

    Now y will be annotated as Readonly<Person> in the hover text.

  4. cdimitroulas commented on Oct 29, 2019

    @cdimitroulas
    Author

    Interesting, that makes sense.

    It would be a bit cumbersome to define the type and the read-only version separately each time in order to improve the intellisense. Let's see what the official TS maintainers have to say about this but I suspect this is not something that can be easily remedied.

  5. craigkovatch commented on Oct 30, 2019

    @craigkovatch

    In short: type aliases are not first-class types. They are more like macros where the compiler expands the alias and uses the expansion directly, which often loses information on where the type originated.

    This is confusing and non-intuitive to me. I think at the very least this should be documented, but if the contract can be improved, or if some control can be afforded via other keywords, that would be a huge help.

  6. RyanCavanaugh commented on Nov 8, 2019

    @RyanCavanaugh
    Member

    I'm not sure that Bruce Pascoe (@fatcerberus) isn't right. This seems like something that "should" work

  7. fatcerberus commented on Nov 8, 2019

    @fatcerberus

    Ryan Cavanaugh (@RyanCavanaugh) I found that if you redeclare ReadOnlyPerson as:

    type ReadOnlyPerson = { readonly name: string }

    then that fixes the issue and y as typed as ReadOnlyPerson in the hover text. It seems that if there is any nesting of type aliases, TS only takes the "innermost" one for the name of the final type. It might also explain all those issues where Omit<> expands to a huge Pick<> type in the hover.

    Repro (Playground):

    type A<T> = Array<T>;
    type B<T> = A<T>;
    type C<T> = B<T>;
    type D<T> = C<T>;
    
    declare let x: D<any>;  // x :: A<any>
  8. AnyhowStep commented on Nov 8, 2019

    @AnyhowStep
    Contributor

    Could be a possible workaround,

    type Person = {
        name: string;
    }
    
    type ReadOnlyPerson = Readonly<{
        name: string;
    }>
    
    // hovering on x shows a type annotation of `Person`
    let x: Person
    // hovering on y shows a type annotation of Readonly<{ name: string }>
    let y: ReadOnlyPerson
    
    interface DoNotExpand extends Readonly<{
        name: string;
    }> {
    
    }
    //let z: DoNotExpand
    let z: DoNotExpand;

    Playground


    I've only ever wanted to force TS to expand types. I've never wanted to force TS to alias types.
    So, this is all new to me =x

    (Now I have h4xx to force TS to expand and not-expand types, yay)

  9. ahejlsberg commented on Nov 18, 2019

    @ahejlsberg
    Member

    The compiler's behaves as intended here. Instantiations of generic type aliases (such as Readonly<T>) are cached and shared based on the type identities of the type arguments. In other words, every reference to Readonly<Foo> in a program ends up referencing the exact same type object. This type of sharing saves both time and memory, but it also precludes associating aliases with particular instantiations (because there could well be multiple possible aliases).

  10. added
    Working as IntendedThe behavior described is the intended behavior; this is not a bug
    and removed
    Needs InvestigationThis issue needs a team member to investigate its status.
    on Nov 18, 2019
  11. locked as resolved and limited conversation to collaborators on Oct 21, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Working as IntendedThe behavior described is the intended behavior; this is not a bug

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions