Skip to content

MappedTypeNodeParser doesn't take Unions into account #2106

@AmadeusK525

Description

@AmadeusK525

Mapped types in TypeScript accept Unions as the constraint type, and the resulting output (in TS) will be similar to Distributive Conditional Types. Take the following code, for example:

type A = {
    a: string;
};

type B = {
    b: number;
};

type MappedUnion<T extends object> = {
    [P in keyof T]: number;
};

export type MyObject = MappedUnion<A | B>;

The resulting type would still be a Union, but with a: string being mapped to a: number, because the mapping is applied to each Union case individually (the LSP shows MyObject to equal MappedUnion<A> | MappedUnion<B>).

The problem is that the MappedTypeNodeParser doesn't handle this correctly, as this case is not covered at all. It generates the constraintType and keyListType (confusing names) by passing mappedNode.typeParameter.constraint to a child parser. The parser will be a TypeOperatorNodeParser, which will, in turn, return all of the possible keys for that type parameter (T). When T is a Union, though, it will return a flat list for its keys. Going back to MappedTypeNodeParser, there's no differentiation if that flat list of keys comes from a Union or an object, it will just assume that it's an object and construct the mapped type:

        if (keyListType instanceof UnionType) {
            // Key type resolves to a set of known properties
            return new ObjectType(
                id,
                [],
                this.getProperties(node, keyListType, context),
                this.getAdditionalProperties(node, keyListType, context),
            );
        }

This means that instead of getting a schema like this for the original example:

"MyObject": {
  "anyOf": [
    {
      "type": "object",
      "properties": {
        "a": {
          "type": "number"
        },
      }
      "required": ["a"]
    },
    {
      "type": "object",
      "properties": {
        "a": {
          "type": "number"
        },
      }
      "required": ["a"]
    }
  ]
}

we get this:

"MyObject": {
  "type": "object",
  "properties": {
    "a": {
      "type": "number"
    },
    "b": {
      "type": "number"
    }
  },
  "required": ["a", "b"]
}

(it, by mistake, turns it into an Intersection by using the flat list of keys for that Union as properties for a new object)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions