Arthur
Arthur

Reputation: 1443

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)
}

Upvotes: 0

Views: 62

Answers (3)

Arthur
Arthur

Reputation: 1443

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!

Upvotes: 0

0xts
0xts

Reputation: 3029

Here's how you can achieve what you're trying to accomplish -

type Keys<T> = keyof T;
type DiscriminatedUnionOfRecord<
    A,
    B = {
        [Key in keyof A as "_"]: {
            [K in Key]: [
                { [S in K]: A[K] extends A[Exclude<K, Keys<A>>] ? never : A[K] }
            ];
        };
    }["_"]
> = Keys<A> extends Keys<B>
    ? B[Keys<A>] extends Array<any>
    ? B[Keys<A>][number]
    : never
    : never;

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

type ShapeType = DiscriminatedUnionOfRecord<typeof shapes>;
type ShapeName = keyof typeof shapes;

type PraseShape<X extends ShapeName> = { [Obj in ShapeType as "_"]: { [Prop in keyof Obj as "_"]: X extends Prop ? { name: Prop, properties: Obj[Prop] } : never } }["_"]["_"];

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

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

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

if (parsed.name === 'square') {

    console.log(parsed.properties.area)
    //                    ^? (property) properties: { area: number; }
}

if (parsed.name === "circle") {
    console.log(parsed.properties.radius);
    //                  ^? (property) properties: { radius: number; }
}

Here's a Playground link. Let me know if I missed something in terms of requirements.

Upvotes: 0

Yaroslavm
Yaroslavm

Reputation: 4804

Probably it can be resolved by creating universal type guard.

UniversalShapeGuard type takes a single type parameter representing the expected shape name. createShapeGuard function then generates a type guard function that can be used to narrow down the type based on the specific shape name.

type UniversalShapeGuard<T extends ShapeName> = (obj: ParsedShape<ShapeName, any>) => obj is ParsedShape<T, ShapeType[T]>;

const shapeGuard = <T extends ShapeName>(name: T): UniversalShapeGuard<T> =>
    (obj: ParsedShape<ShapeName, any>): obj is ParsedShape<T, ShapeType[T]> => obj.name === name;

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

if (shapeGuard('circle')(parsed)) {
    console.log(parsed.properties.radius)
}

Test is here

Upvotes: 0

Related Questions