colinhacks / zod

TypeScript-first schema validation with static type inference
https://zod.dev
MIT License
33.72k stars 1.17k forks source link

Inferred recursive types without casting #99

Closed bradennapier closed 4 years ago

bradennapier commented 4 years ago

Interesting in more information on what is holding back nested types from being simpler? I can't seem to think of what it could be as I can model and infer things on a pretty insanely sophisticated level -- but I am sure I am just not understanding a piece of the chain here.

colinhacks commented 4 years ago

Not sure what you mean here. What sort of API would you be looking for?

PS I've seen your other issues - gonna get to them as soon as I can. That VS Code extension is literal magic 🤙

bradennapier commented 4 years ago

I was just wondering because in docs you mention that nested types require the whole lazy() approach due to a limitation in TypeScript, but I can't think of what that would be. Basically, what stops us from being able to model nested types by just adding schemas together?

I admit, my actual experience with Zod is basically none so far - we would not be able to move to a new validation library without something like my vscode extension, it would just be too much work - which is why I toyed with doing that.

Prob should have used Recursive Types not nested types, my bad.

const SomeObj = z.object({
      three: z.literal("hi").optional(),
      one: z.number(),
    })
const TestSchema = z.object({
  three: z.literal("hi").optional(),
  one: z.number(),
  two: z.literal(3),
  four: SomeObj,
  five: z.array(
    z.object({
      three: z.literal("hi").optional(),
      one: z.number(),
      two: z.literal(3),
      four: SomeObj,
      five: z.date(),
    })
  ),
});
colinhacks commented 4 years ago

Gotcha. The example you gave is nested, but not recursive (and it works fine as is!).

The limitation I'm referring to is with inferring the static type of a recursive schema. Fortunately it's extremely easy to see this limitation in action. Just make one of those types recursive:

const SomeObj = z.object({
  three: z.literal('hi').optional(),
  one: z.number(),
  anotherSomeObj: z.lazy(()=>SomeObj)
});

And you'll get an error like this: Screen Shot 2020-07-25 at 7 44 03 PM

There's no way around this unless you tell TypeScript what the inferred type should be (in which case it's not really "inferred" anymore 🤔):

type SomeObjType = { three?: "hi"; one: number; anotherSomeObj: SomeObjType };

const SomeObj: z.Schema<SomeObjType> = z.object({
  three: z.literal('hi').optional(),
  one: z.number(),
  anotherSomeObj: z.lazy(()=>SomeObj)
});
bradennapier commented 4 years ago

Does that remain the case if you rebuild the type and infer that by mapping it first via something like below? I guess it probably would since that would depend on providing the type - yeah I get it now..

export type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
bradennapier commented 4 years ago

moved to its own comment since its logically separated from above ;-)

although I wonder if there is actually a workaround to it -- perhaps via using a getter, which Typescript has optimized for recursive typing quite well. It doesn't work when applying Zod types and may not work in general due to the nature of how Zod needs to operate... but it may be one avenue to explore

const obj = {
  get test() {
    return obj;
  },
  one: 2,
};

image

type TestTest = typeof obj['test'];

image

const Test = z.object({
    get recursed() {
       return Test
    },
    one: z.number()
})
bradennapier commented 4 years ago

Yep - see now the deeper problem

colinhacks commented 4 years ago

I'll grant you: you got my hopes up :D But yeah this seems to be an unbreakble law of the universe (until Microsoft decides it's not)

bradennapier commented 4 years ago

Ok - flip flopping again - I am growing confident there is a way to achieve this. It may require some rethink around how things are defined but I think it would be possible.

To demonstrate I am using unique symbol props here and casting them to achieve inference of type... this may be an approach that could work to achieve the desired result

const NUMBER = Symbol.for('number') as any as number;
const STRING = Symbol.for('string') as any as string;

const obj = {
  one: NUMBER,
  two: STRING,
  get obj() {
    return [obj]
  }
} as const

type Inferred = typeof obj

type ArrType = Inferred['obj']

type InferDeepType = Inferred['obj'][number]['obj'][number]['one']

image

image


I think the key here would be that if you do it this way - you'd define the schema then wrap it with a single zod - this would also make it insanely simple to use normal javascript to create validators...


type OmitByValue<T extends { [key: string]: any }, ValueType> = Pick<
  T,
  { [Key in keyof T]-?: T[Key] extends ValueType ? never : Key }[keyof T]
  >;

type Infer<T> = OmitByValue<T, ((...args: any[]) => any)>

function zod<O extends { [key: string]: any }>(obj: O): O & { validate(): void } {
  return Object.create(obj, {
    validate: {
      value() {
        console.log('validate!')
      }
    }
  } )
}

const PartObj = {
  one: NUMBER
}

const obj = {
  ...PartObj,
  two: STRING,
  three: 3,
  get obj() {
    return [obj]
  }
} as const

const ValidateObj = zod(obj)
const ValidatePartObj = zod(PartObj)

type Inferred = Infer<typeof ValidateObj>

type ArrType = Inferred['obj']

type OneType = ArrType[number]['one']

type InferDeepType = Inferred['obj'][number]['obj'][number]['one']
bradennapier commented 4 years ago

Lastly, just to paint how powerful this might be, this also works. These are just examples but they would definitely be capable of working in a way that would be pretty powerful and might give some ideas! I would probably be interested in exploring further should there be interest in seeing how something as such may be implemented.

const obj = {
  ...PartObj,
  ...PartObj2,
  three: 3,
  four: STRING_MATCH_CUSTOM,
  five: zod.union([STRING, NUMBER]),
  six: zod.intersection([PartObj, PartObj2]),
  get obj() {
    return [obj]
  }
} as const

image

colinhacks commented 4 years ago

@bradennapier well this is wild and fantastic. I don't think anyone else has figured out how to do this.

Implementing something like this in Zod would be really hard though. I think it'd be valuable to spin out these ideas into their own library if you have time. I'd be curious to know whether there's a way to "class-ify" this approach so you can add additional refinements, have methods like pick/omit, etc.

bradennapier commented 4 years ago

I mean I demonstrate pick there using object spread. Although it could easily work the other way as well... object spread is nice though

bradennapier commented 4 years ago

The actual object is just a normal object with symbols as values which each represent a type but are casted to the type they represent.

UNION is similar as [...symbols] but add the symbol as a property or the array, and intersection is the same. Also played with [UNION, ...values] which is more powerful and allows some interesting additions, but for it to work well it requires 4.0's variadic tuples

Actual literals are just literals. So an array without is a tuple.

In that sense, since just normal JavaScript for most part, can act on them like any other js And just wrap it to turn it into a schema (which literally just gives it the validate fn.

I have it all working type wise - not sure I want to build out a lib on my own though. Lol.

https://github.com/bradennapier/ts-type

here is what i was playing with in case you wanna take a look. I broke it a lot starting to put pieces together and was playing around with ideas with proxies (which wouldn't be needed, was playing with the idea of allowing someone to validate any prop by mutating it schema.union = value (runtime error if value is not the expected type so can lazily validate as required and memoize result if checked against multiple times)) and such but the typing should work - was playing with that on the main wrap file for ref https://github.com/bradennapier/ts-type/blob/master/src/wrap.ts and the bulk of the type magic is done https://github.com/bradennapier/ts-type/blob/master/src/types/types.ts#L39

const one = { one: wrap.optional.string(), four: 3 } as const;
const two = { two: 2 } as const;
// lets do some recursive
const three = {
  five: { foo: wrap.optional.string(), bar: 'bar', one, ...two },
} as const;

const validator = {
  foo: wrap.optional.string(),
  bar: wrap.nullable.optional.literal('bar'),
  baz: 123,
  qux: 'qux',
  // TODO wrap.not.equal(2) - not working for all values, confirmed typing can work in
  // some cases even for example wrap.primitive.not.equal.string.number was typable to all
  // primitives but string | number
  blah: wrap.unequal(2),
  union: wrap.optional.nullable.union([wrap.string(), wrap.number()]),
  intersect: wrap.intersection([one, two, three]),
  key: wrap.never(),
  undef: wrap.undefined(),
} as const;

export const schema = wrap(validator);

export type Inferred = Infer<typeof schema>;

const obj: Inferred = {
  foo: 'one',
  bar: 'bar',
  baz: 123,
  qux: 'qux',
  blah: 3,
  union: 2,
  intersect: {
    one: 'hi',
    four: 3,
    two: 2,
    five: {
      bar: 'bar',
      two: 2,
      one: {
        one: 'string',
        four: 3,
      },
    },
  },
};

The above is statically inferred by TS as:

type Inferred = {
    qux: "qux";
    baz: 123;
    blah: unknown;
    intersect: {
        four: 3;
        two: 2;
        five: {
            bar: "bar";
            one: {
                four: 3;
                one?: string | undefined;
            };
            two: 2;
            foo?: string | undefined;
        };
        one?: string | undefined;
    };
    union?: string | number | null | undefined;
    bar?: string | null | undefined;
    foo?: string | undefined;
    key?: undefined;
    undef?: undefined;
}
bradennapier commented 4 years ago

And just for good fun, here is an example of pick:

// example of a pick fn
const picked = {
  foo: wrap.string(),
  obj: wrap.pick(schema, ['intersect', 'union']),
} as const;

export const pickedSchema = wrap(picked);

export type PickedInferred = Infer<typeof pickedSchema>;
type PickedInferred = {
    foo: string;
    obj: {
        intersect: {
            four: 3;
            two: 2;
            five: {
                bar: "bar";
                one: {
                    four: 3;
                    one?: string | undefined;
                };
                two: 2;
                foo?: string | undefined;
            };
            one?: string | undefined;
        };
        union?: string | number | null | undefined;
    };
}

and merge

const merged = wrap(wrap.merge([schema, pickedSchema]));

type MergeInferred = Infer<typeof merged>;
type MergeInferred = {
    qux: "qux";
    foo: string;
    baz: 123;
    blah: unknown;
    intersect: {
        four: 3;
        two: 2;
        five: {
            bar: "bar";
            one: {
                four: 3;
                one?: string | undefined;
            };
            two: 2;
            foo?: string | undefined;
        };
        one?: string | undefined;
    };
    obj: {
        intersect: {
            four: 3;
            two: 2;
            five: {
                bar: "bar";
                one: {
                    four: 3;
                    one?: string | undefined;
                };
                two: 2;
                foo?: string | undefined;
            };
            one?: string | undefined;
        };
        union?: string | number | null | undefined;
    };
    union?: string | number | null | undefined;
    bar?: string | null | undefined;
    key?: undefined;
    undef?: undefined;
}

anyway, was just a fun little experiment to see how far it could go. turned out pretty powerful - perhaps will spark some ideas for you or someone :-)