Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validate schema against existing type #3

Open
karlhorky opened this issue Jul 25, 2023 · 23 comments
Open

Validate schema against existing type #3

karlhorky opened this issue Jul 25, 2023 · 23 comments
Assignees
Labels
enhancement New feature or request priority This has priority workaround Workaround fixes problem

Comments

@karlhorky
Copy link

Hi, first of all, thanks for Valibot, looks really cool 🙌

One feature that I was looking for was Yup's Ensuring a schema matches an existing type, which could look like this in Valibot:

import { type ObjectSchema, email, minLength, object, string } from 'valibot';

type LoginInput = {
  email: string;
  password: string;
};

// Check that the schema conforms to the `LoginInput` type
const LoginSchema: ObjectSchema<LoginInput> = object({
  email: string([email()]),
  password: string([minLength(8)]),
});

What do you think of this feature?

@fabian-hiller
Copy link
Owner

I haven't had any use for it yet, but understand why it can be useful. Thank you creating this issue.

@fabian-hiller fabian-hiller self-assigned this Jul 25, 2023
@fabian-hiller fabian-hiller added the enhancement New feature or request label Jul 25, 2023
@Arthie
Copy link

Arthie commented Jul 25, 2023

Example to check if parsed data satisfies an existing type using the satisfies keyword, this will work for all kind of errors:

import { email, minLength, object, parse, string } from "valibot";

type LoginInput = {
  email: string;
  password: string;
};

const LoginSchema = object({
  email: string([email()]),
  password: string([minLength(8)]),
});

const LoginErrorSchema = object({
  email: string([email()]),
});

let input = parse(LoginSchema, {}) satisfies LoginInput; // OK

let inputError = parse(LoginErrorSchema, {}) satisfies LoginInput; // ERROR: Property 'password' is missing in type '{ email: string; }' but required in type 'LoginInput'.

I've tried to create a helper type to use the satisfies keyword with a ObjectSchema type, but quickly had to go to complex typing and there would be tons of edge cases. This monster of a type would probably be out of scope for a simple library like this one.

My current recommendation would be to use the satisfies keyword on parsed data, since the satisfies keyword was made for this kind of validation. At least until there is a simple way to use, satisfies with ObjectSchema type!
Hope this helps someone!

@fabian-hiller
Copy link
Owner

Thank you @Arthie for all the info!

@fabian-hiller fabian-hiller added the workaround Workaround fixes problem label Dec 10, 2023
@fabian-hiller
Copy link
Owner

fabian-hiller commented Feb 28, 2024

This is possible with BaseSchema:

import * as v from 'valibot';

type Login = {
  email: string;
  password: string
};

export const Login = v.object({
  email: v.string(),
  password: v.string(),
}) satisfies v.BaseSchema<Login>;

@karlhorky
Copy link
Author

@fabian-hiller Amazing, thanks!

Just to confirm, does this show an error in both of the cases below?

  1. Extra property in Valibot schema
import * as v from 'valibot';

type Login = {
  email: string;
  password: string
};

export const Player = v.object({
  email: v.string(),
  password: v.string(),
  x: v.string(), // 💥 Error expected here (extra `x` property)
}) satisfies v.BaseSchema<Login>;
  1. Missing property in Valibot schema
import * as v from 'valibot';

type Login = {
  email: string;
  password: string
};

export const Player = v.object({
  email: v.string(),
  // 💥 Error expected here (missing `password` property)
}) satisfies v.BaseSchema<Login>;

@fabian-hiller
Copy link
Owner

The second works, but the first does not. The reason is that satisfies only checks the input and output type, but not the structure of the object definition.

You can also convert TypeScript types to a Valibot schema on this website: https://sinclairzx81.github.io/typebox-workbench/

@karlhorky
Copy link
Author

karlhorky commented Feb 28, 2024

Ok, for this feature, the first one would also need to be an error to prevent excess properties being passed. This would be important if eg. adding the validated object to a database - would be problematic if inconsistent with the type.

Would you consider reopening the issue and adding a feature to allow the first too? Eg. via a type annotation?

@fabian-hiller
Copy link
Owner

I have no idea how to implement such a feature. Feel free to research it and share a proof of concept with me.

@karlhorky
Copy link
Author

Hm... maybe the generic type parameter could be passed into the v.object() schema method, similar to the approach in the exact object blog post by @ddprrt

https://fettblog.eu/typescript-match-the-exact-object-shape/

@fabian-hiller
Copy link
Owner

This would require a breaking change and more cumbersome code in many cases. Can you explain why such a feature would be useful? Shouldn't the Valibot schema be the source of truth for type and validation?

@karlhorky
Copy link
Author

karlhorky commented Feb 28, 2024

Shouldn't the Valibot schema be the source of truth for type and validation?

I think that for some applications, the source of truth is closer to where the data lives (eg. the database), not the validation layer with the user, which in many cases is an incomplete representation of the data model.

We have our source of truth as types in our database files, where SafeQL type-checks them against the SQL queries we write.

@fabian-hiller fabian-hiller reopened this Mar 1, 2024
@fabian-hiller
Copy link
Owner

Thank you very much! I understand the use case and can see why this is useful. I will have a look at how other schema libraries solve this in the next few weeks.

@fabian-hiller fabian-hiller added the priority This has priority label Mar 1, 2024
@fabian-hiller
Copy link
Owner

I think this is a feature I will investigate further once Valibot v1 is available. If more people want this feature, please give this comment a thumbs up.

@groteck
Copy link

groteck commented Jul 15, 2024

This will be a game changer to my projects.

I like to validate my open api types and use valibot/zod/yup to validate my partial forms in a stepper. With that check I can get a compilation error if a new field is added and my contract doesn't match the schema any more.

I tested the suggested workaround but looks like at some point the BaseSchema<T> changed to BaseSchema<TInput, TOutput, TIssue extends BaseIssue<unknown>> so the satisfies trick doesn't work any more.

@fabian-hiller
Copy link
Owner

fabian-hiller commented Jul 15, 2024

Since we rewrote the whole library with v0.31.0, our implementation changed a bit. Just change BaseSchema to GenericSchema.

import * as v from 'valibot';

type Test = {
  field1?: string,
  field2?: string,
}

const TestSchema = v.object({
  field1: v.optional(v.string()),
  field2: v.optional(v.string()),
}) satisfies v.GenericSchema<Test>;

@karlhorky
Copy link
Author

karlhorky commented Jul 16, 2024

@fabian-hiller does the satisfies solution handle both incorrect excess property checking and incorrect missing property checking?

If not, maybe consider implementing / copying an implementation like Exact<T, U> from ts-toolbelt

import { Exact } from 'ts-toolbelt/out/Function/Exact'

function exactObject<A>(x: Exact<A, {a: number, b: 2}>) {}

// ok
exactObject({} as {a: 1, b: 2})

// errors
exactObject({} as {a: 1, b: 2, c: 3})
exactObject({} as {a: 1})

Screenshot 2024-07-16 at 11 42 36
Screenshot 2024-07-16 at 11 42 42

Or one of the other many implementations of exact types (long thread, expand the hidden comments):

@fabian-hiller
Copy link
Owner

does the satisfies solution handle both incorrect excess property checking and incorrect missing property checking?

Unfortunately, it does not. It will error if you miss a non-optional property in your schema. That's probably the most important check. But otherwise it is not 100% accurate. I am not sure if Exact<T, U> can help us here.

@groteck
Copy link

groteck commented Jul 16, 2024

In my case I found that the workflow is that: valibot needs to "satisfy" the external type, but forms have extra fields and things that maybe will be map o transform into others, for example "optional" fields or transformed attributes on the way to the API like Dates . So I create the Valibot Object, and satisfies a type that I "hamer" with different Exclude and utility types (Exact can be one of them), or removing nulls because is usually my classic pain dealing with APIs auto generated types.

@karlhorky
Copy link
Author

I am not sure if Exact<T, U> can help us here.

@fabian-hiller What's your rationale behind this feeling? Did you check the playground above? It shows type checking of both excess property checks and missing property checks.

@fabian-hiller
Copy link
Owner

I may be wrong. Feel free to create a proof of concept showing that a schema fails if its type does not match a predefined type.

@jamiebuilds
Copy link

Additional Concerns

With the addition of TypeScript's --isolatedDeclarations feature I think this is much more important now. If you attempt to use this flag and export a Valibot schema you will get the following error:

// ts(9010): Variable must have an explicit type annotation with --isolatedDeclarations.
export let schema = v.string()

Forcing you to write types like this:

export let schema: v.GenericSchema<string> = v.string()

When you're being forced to maintain types and schemas separately, it's much more likely that you are going to run into the issues with nullability and additional properties.

Solution

I think I've found a workable solution for enforcing the shape of object types by modifying the types in this comment for Zod: colinhacks/zod#372 (comment)

Usage

export type Person = {
	name: string
  bio?: string
}

export let PersonSchema = v.object({
  name: v.string(),
  bio: v.optional(v.string()),
} satisfies Shape<Person>)

Implementation

[TypeScript Playground]

import type { GenericSchema, NullishSchema, OptionalSchema, NullableSchema, Default } from "valibot"

type ShapeNullishSchema<TSchema extends GenericSchema<unknown>> = NullishSchema<TSchema, Default<TSchema, null | undefined>>
type ShapeOptionalSchema<TSchema extends GenericSchema<unknown>> = OptionalSchema<TSchema, Default<TSchema, undefined>>
type ShapeNullableSchema<TSchema extends GenericSchema<unknown>> = NullableSchema<TSchema, Default<TSchema, null>>

type Shape<TObject extends object> = {
  [TKey in keyof TObject]-?:
    // Check for --strictNullChecks
    null extends {} ? { "You should not use Shape<T> without using --strictNullChecks": never } :
    undefined extends TObject[TKey]
      ? null extends TObject[TKey]
        ? ShapeNullishSchema<GenericSchema<TObject[TKey]>>
        : ShapeOptionalSchema<GenericSchema<TObject[TKey]>>
      : null extends TObject[TKey]
        ? ShapeNullableSchema<GenericSchema<TObject[TKey]>>
        : GenericSchema<TObject[TKey]>;
};

Examples

No extra properties:

type Person = { 
  name: string
}
let PersonSchema = v.object({
  name: v.string(), // OK
  email: v.string(), // Error
} satisfies Shape<Person>)

Nullable, Nullish, Optional (void, undefined, or ?) are all enforced:

type Person = { 
  name: string | null
  email: string | null | undefined
  bio: string | undefined
  website?: string
  github?: string | null
}
let PersonSchema = v.object({
  name: v.string(), // Error (missing nullable)
  email: v.nullish(v.string()),
  bio: v.optional(v.string()),
  website: v.optional(v.string()),
  github: v.optional(v.string()), // Error (should be nullish, not optional)
} satisfies Shape<Person>)

Defaults work just fine:

type Person = {
  name: string | null
  bio: string | null
}
let PersonSchema = v.object({
  name: v.nullable(v.string(), "Anonymous"),
  bio: v.nullable(v.string(), () => "I'm new here")
} satisfies Shape<Person>)
let person = v.parse(PersonSchema, { name: null, bio: null })
// >> { name: string

Naming

I went with the name Shape<T> for this, but some alternatives:

  • ObjectShape<T>
  • ObjectSatisfies<T>
  • Implements<T>

strictNullChecks

Note that this does require you to have strictNullChecks: true, otherwise every property would be treated as if it should be marked nullish() because that's effectively the type you are describing all the time with strictNullChecks: false.

@fabian-hiller
Copy link
Owner

Thank you for your research. The Shape type looks interesting and useful to me. Are you able to implement it in a way that it allows to define not only the input type but also the output type?

How is the Shape type related to what you wrote about --isolatedDeclarations?

@Chinoman10
Copy link

@jamiebuilds are there any updates on this proposed solution?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request priority This has priority workaround Workaround fixes problem
Projects
None yet
Development

No branches or pull requests

6 participants