ianstormtaylor / superstruct

A simple and composable way to validate data in JavaScript (and TypeScript).
https://docs.superstructjs.org
MIT License
7.02k stars 224 forks source link

Describe throws typescript a error with two objects in a union #724

Open cthogg opened 3 years ago

cthogg commented 3 years ago

Thanks for the great library!

I have stumbled on the following when I use use two objects in a union and using Describe

for example:

const ProgressWithUnionTwoObjects = s.object({
  data: s.union([
    s.object({ message: s.string() }),
    s.object({ number: s.number() })
  ])
});

const testCase = {
  data: { message: "bar" }
};

const superStructFunction = <T extends {}>(
  input: any,
  superStruct: s.Describe<T>
) => {
  return s.validate(input, superStruct);
};

const assertion = superStructFunction(
    testCase,
    ProgressWithUnionTwoObjects
  );

throws:

Argument of type 'Struct<{ data: { message: string; } | { number: number; }; }, { data: Struct<{ message: string; } | { number: number; }, null>; }>' is not assignable to parameter of type 'Describe<{ data: { message: string; } | { number: number; }; }>'.
  Types of property 'schema' are incompatible.
    Type '{ data: s.Struct<{ message: string; } | { number: number; }, null>; }' is not assignable to type '{ data: Describe<{ message: string; } | { number: number; }>; }'.ts(2345)

I have created an example here of the issue. What is confusing for me is that there is no error when a number is added to the union

https://codesandbox.io/s/dreamy-ardinghelli-8e51j?file=/src/App.tsx:747-795

ianstormtaylor commented 3 years ago

Hey @cthogg thanks for the report. Can you try to pare it down to the most minimal reproduction? (Eg. getting rid of the nested objects, intermediate functions, etc. if possible.)

cthogg commented 3 years ago

Sure no problem!

I hope this is clearer.

https://codesandbox.io/s/rwpoe?file=/src/structs.ts

ianstormtaylor commented 3 years ago

Thanks, I think this is the most minimal reproduction I can make that exhibits similar behavior:

import { Describe, validate, string } from 'superstruct'
const fn = <T>(struct: Describe<T>) => validate(null, struct)
const S = string()
fn(S) // fails
Argument of type 'Struct<string, null>' is not assignable to parameter of type 'Describe<string | null>'.
  Types of property 'refiner' are incompatible.
    Type '(value: string, context: Context) => Iterable<Failure>' is not assignable to type '(value: string | null, context: Context) => Iterable<Failure>'.
      Types of parameters 'value' and 'value' are incompatible.
        Type 'string | null' is not assignable to type 'string'.
          Type 'null' is not assignable to type 'string'.ts(2345)

It looks like something to do with how Describe handles null for some reason, which makes it impossible to use it with a generic. I'm not sure why that's happening, but if you can find a solution I'd be happy to merge a pull request.

This may be running into limitations in TypeScript.

marlonjan commented 3 years ago

Had a (somewhat) similar issue with Describe and nullability recently:

type X = {
    a: number;
};

const XStruct: s.Describe<X> = s.object({
    a: s.number(),
});

// So far everything works as expected.
// Now let's assume we have to define a custom guard for X,
// because X might be defined in an external library,
// i.e. the library does not provide a superstruct struct for the static type.

const isX = (x: unknown): x is X => {
    return 'a' in (x as X);
};

// Here we run into a compile error:
const XStructFromGuard: s.Describe<X> = s.define('X', isX);
// TS2322: Type 'Struct<X, null>' is not assignable to type 'Describe<X>'.
//     Types of property 'schema' are incompatible.
//         Type 'null' is not assignable to type '{ a: Describe<number>; }'.