Search code examples
typescriptgenericsdiscriminated-uniondiscriminator

TypeScript generics and nested object discrimination of a parsed string


Let's say I have this type code:

const shapes = {
    circle: {
        radius: 10
    },
    square: {
        area: 50
    }
}

type ShapeType = typeof shapes
type ShapeName = keyof ShapeType

type ParsedShape<NAME extends ShapeName, PROPS extends ShapeType[NAME]> = {
    name: NAME,
    properties: PROPS
}

Now, I want to use the key of the shapes object as the shape name when serialising. But after deserialisation I want to be able to figure out which shape it was. So I have this deserialisation code:

const parseShape = (json: string): ParsedShape<ShapeName, ShapeType[ShapeName]> => {
    const parsed = JSON.parse(json)

    return {
        name: parsed.name,
        properties: parsed.properties
    }
}

The problem is - I am not able to discriminate the properties of the shape using name:

const parsed = parseShape('{"name": "square", "properties": {"area": 50}}')

if (parsed.name === 'square') {
    //ERROR
    //Property area does not exist on type { radius: number; } | { area: number; } 
    //Property area does not exist on type { radius: number; }
    console.log(parsed.properties.area)
}

So TypeScript is not seeing that I am actually checking for the shape name and is not narrowing down the properties.

Is there a way to achieve what I want or it is not possible?

An ugly workaround I am currently using is this and I would rather avoid that if possible:

type ParsedShape<NAME extends ShapeName> = {
    [shapeName in NAME]?: ShapeType[shapeName]
}

const parseShape = (json: string): ParsedShape<ShapeName> => {
    const parsed = JSON.parse(json)

    return {
        [parsed.name]: parsed.properties
    }
}

const parsed = parseShape('{"name": "square", "properties": {"area": 50}}')

if (parsed.square) {
    console.log(parsed.square.area)
}

Solution

  • Okay, so after a few answers I've got nudged into the right direction and found what I wanted. I took the code suggested by @0xts and asked GPT4 if it can be simplified, and that got me closer to the final result.

    Basically, the "workaround" I was using was pretty close to the goal (notice the }[NAME] at the end of the type declaration):

    type ParsedShape<NAME extends ShapeName> = {
        [shapeName in NAME]: {
            name: shapeName
            properties: ShapeType[shapeName]
        }
    }[NAME]
    
    const parseShape = (json: string) => {
        return JSON.parse(json) as ParsedShape<ShapeName>
    }
    
    const parsed = parseShape('{"name": "square", "properties": {"area": 50}}')
    
    if (parsed.name === 'square') {
        console.log(parsed.properties.area)
    }
    

    Thanks everyone!