Search code examples
typescriptmapped-types

Typescript mapped type error when type is annotated


Not sure if the title is explicit (it was not easyto describe this issue in one sentence) but here is some code to illustrate the issue (more details about the issue after the code block):

type AttributeDefinition = {
    name:string
    type: 'string' | 'number',

}
type EntityDefinition = {
    attributes: Readonly<AttributeDefinition[]>
}

export type Entity<ET extends EntityDefinition> = {
    [K in ET['attributes'][number] as K['name']]:
    K['type'] extends 'string' ? string :
    K['type'] extends 'number' ? number :
    never
}

const def1 = {
    attributes: [
        {name:'foo', type: 'string'},
        {name:'baz', type: 'number'},
    ] as const
}

const def2: EntityDefinition = {
    attributes: [
        {name:'foo', type: 'string'},
        {name:'baz', type: 'number'},
    ] as const
}

const entity1: Entity<typeof def1> = {
    foo: 'bar',
    baz: 42
}

const entity2: Entity<typeof def2> = {
    foo: 'bar',
    baz: 42
}

So my issue is that typescript throw an error on entity2 when I want to assign 'bar' to foo (and 42 to baz). The issue is TS2322: Type 'string' is not assignable to type 'never'.

However, this issue does not occurs with entity1.

The origin of this issue seems to be related to the declaration of def2 for which I annotate the EntityDefinition type. I am doing this so I can have typing hint from my IDE helping me to properly declare the def2 object.

But the def1 object, even if not type annotated, is later properly handled when creating the entity1, whereas def2 (that I would expect to be typed the right way) fails when it is used to create the entity2.

I am not really sure to understand why it does fail in such a way, and how can I annotate a object as I do for def2 and also use such an object later as I do for entity1.

Thanks!


Solution

  • The reason Entity<typeof def2> fails is that the annotation widens the type of def2, which causes the specifics to get lost. The type of Entity<typeof def2> is equal to Entity<EntityDefinition>, which evaluates to {[x: string]: never}.

    You could probably create a generic type annotation that preserves the information, but then you'd have to specify the generic parameters in the constant type, which would lead to duplicated code.

    On approach to deal with this is to create a constructor function that just returns the argument but has a constraint on it:

    const mkEntityDefinition = <T extends EntityDefinition>(def: T) => def
    

    You can use it like this:

    const def3 = mkEntityDefinition({
        attributes: [
            {name:'foo', type: 'string'},
            {name:'baz', type: 'number'},
        ] as const
    })
    
    export const entity3: Entity<typeof def3> = {
        foo: 'bar',
        baz: 42
    }
    

    and it will prevent mistakes:

    const defBad = mkEntityDefinition({
        attributes: [ 
            {name:'foo', type: 'strrring'},
            {name:'baz', type: 'number'},
        ] as const
    })
    // Error: Type '"strrring"' is not assignable to type '"string" | "number"'.
    // Did you mean '"string"'?
    

    TypeScript playground