Search code examples
javascriptzod

Union of extended types get parsed as base type


I want to parse an array of different schemas that depend on a base type :

// Simple example
const typeA = z.object({ prop: z.string() });

const typeB = typeA.extend({
  extraProp: z.boolean(),
});

I tried with the following schema :

const schema = z.array(z.union([typeA, typeB]));

The thing is when I parse the data, the typeB items get parsed as type A, so the extraProp is removed from the output

const data = schema.parse([{ prop: 'foo', extraProp: true }]);
// => [{"prop":"foo"}]

When I put typeB as the first item of the union, it seems to output the right type, but when wrong data is sent, it is parsed to typeA, :

const schema = z.array(z.union([typeB, typeA]));

const data = schema.parse([{ prop: 'foo', extraProp: true }]);
// => [{"prop":"foo","extraProp":true}]

...

const data = schema.parse([{ prop: 'foo', extraProp: 4 }]);
// => [{"prop":"foo"}] - should fail here

Is my schema wrong here or is it something that zod is not capable of?


Solution

  • From zod's Unions section (emphasis added):

    Zod will test the input against each of the "options" in order and return the first value that validates successfully.

    Nothing more and nothing less: in z.union([typeA, typeB]) it tries typeA and if it validates, returns the parsed data, if not, it would try typeB, and so on.

    One tends to think that it computes some form of a united object schema, but it doesn't. Which explains why the last example validates: typeB fails, then it just parses typeA, like there's no typeB.

    And one is right to expect that - it doesn't validate, for instance, in TypeScript:

    type A = {prop: string};
    type B = A & {extraProp: boolean};
    type Sch = B | A;
    
    const o: Sch = { prop: 'foo', extraProp: 2 }; 
    // error TS2322: type 'number' is not assignable to type 'boolean' 
    

    The construct that would emulate the expected behavior in both case is .merge:

    const typeA = z.object({ prop: z.string() });
    const typeB = typeA.extend({
       extraProp: z.boolean(),
    });
    
    const schema = z.array(typeB.merge(typeA));
    const data = schema.parse([{ prop: 'foo', extraProp: 2 }]);
    //{prop: 'foo', extraProp: true}
    
    // ......
    const schemaBA = z.array(typeB.merge(typeA));
    schemaBA.parse([{ prop: 'foo', extraProp: 2 }]);
    
    //Uncaught ZodError: [{
    //  //......
    //  "message": "Expected boolean, received number"
    // }]