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

[Feature Request]: Meta data/labels #273

Closed
MFogleman opened this issue Dec 18, 2020 · 13 comments
Closed

[Feature Request]: Meta data/labels #273

MFogleman opened this issue Dec 18, 2020 · 13 comments

Comments

@MFogleman
Copy link

MFogleman commented Dec 18, 2020

I saw this being mentioned before in another issue, but I'd like to make the request again with a slightly different user story.

My team is looking at replacing Joi with ZodV2 in its Hapi microservice. We really like that we can create an input validation for a handler, and have it tightly coupled to the handler's input's type definition. So there is no more worrying about, "Oh, is the validation wrong, or is the input's typing wrong". Its the same.

The only loose thread is documentation. We want to create something that parses our Hapi route objects, sees the Zod object we have passed to the validator, and generates a documentation page for that route.

Simply parsing the ZodObject isn't quite cutting it. It ends up lacking information and context that we'd like, between refinements, transformations, and things that we just want to have more explanation on. If individual Zod elements could have a .label or a .meta property, it'd give us a bit more flexibility in creating documentation that is tightly coupled with our zod schema.

@colinhacks
Copy link
Owner

As I've suggested in other issues, I recommend doing this by wrapping Zod.

type MyEndpoint<T extends z.Schema<any>> = {
  validator: T;
  label: string;
}

Bringing this inside Zod is a huge footgun and encourages lots of anti-patterns that would be better solved by either creating higher-level constructs that include a Zod schema (like MyEndpoint) or using factory/generator functions that return a schema.

@everhardt
Copy link

I cannot get the recommendation to work when you want to identify sub schema's. For example, take:

const seat = z.object({
  color: z.string(),
  height: z.number(),
});

const car = z.object({
  frontLeftSeat: seat,
  frontRightSeat: seat,
  rearLeftSeat: seat,
  rearRightSeat: seat,
});

When generating documentation for car, I'd like to identify seat as a reused model and display it as Seat in the documentation, linking to the Seat model, instead of inlining it every time.

@everhardt
Copy link

Sorry, I worked it out in the end:

import { z, AnyZodObject } from 'zod';

const seat = z.object({
  color: z.string(),
  height: z.number(),
});

const mirror = z.object({
  color: z.string(),
  width: z.number(),
});

interface Model {
  schema: AnyZodObject;
  label: string;
}

const models: Model[] = [
  { schema: seat, label: 'Seat' },
  { schema: mirror, label: 'Mirror' },
];

const car = z.object({
  frontLeftSeat: seat,
  frontRightSeat: seat,
  rearLeftSeat: seat,
  rearRightSeat: seat,
  leftMirror: mirror,
  rightMirror: mirror,
});

Object.entries(car.shape).forEach(([key, sub]) => {
  const model = models.find((m) => m.schema === sub);
  if (model !== undefined) {
    console.log(`${key}: ${model.label}`);
  }
});

gives

frontLeftSeat: Seat
frontRightSeat: Seat
rearLeftSeat: Seat
rearRightSeat: Seat
leftMirror: Mirror
rightMirror: Mirror

@lukemt
Copy link

lukemt commented Jun 4, 2022

Yeah, but...

What if I want to use the schema as a whole?

For example the data of your example would be this:

const data = {
    frontLeftSeat: {
        color: 'red',
        height: 100,
    },
    frontRightSeat: {
        color: 'blue',
        height: 200,
    },
    rearLeftSeat: {
        color: 'green',
        height: 300,
    },
    rearRightSeat: {
        color: 'yellow',
        height: 400,
    },
    leftMirror: {
        color: 'black',
        width: 50,
    },
    rightMirror: {
        color: 'white',
        width: 100,
    },
}

How would I go about validating the data object?

This is the main problem I currently have within my usage and I don't know a way to do this (without loosing type interference at least).

I really like zod because of its simplicity, but this point is something that I have really no idea on how to accomplish this, which unfortunately makes me thinking about switching to yup which I actually like a bit less.

Any ideas welcome

@scotttrinh
Copy link
Collaborator

Isn't that the car schema in that example?

@lukemt
Copy link

lukemt commented Jun 4, 2022

Ah, you're right, I misinterpreted this.

Anyways defining all the schemas as variables doesn't really cut it for me

@lukemt
Copy link

lukemt commented Jun 5, 2022

What I was imagining he had done was something like this:

import { z, AnyZodObject } from 'zod';

const seatSchema = z.object({
  color: z.string(),
  height: z.number(),
});

const mirrorSchema = z.object({
  color: z.string(),
  width: z.number(),
});

interface Model {
  schema: AnyZodObject;
  label: string;
}

const seat:Model = {
  schema: seatSchema,
  label: "Seat",
}
const mirror:Model = {
  schema: mirrorSchema,
  label: "Mirror",
}

const car = z.object({
  frontLeftSeat: seat,
  frontRightSeat: seat,
  rearLeftSeat: seat,
  rearRightSeat: seat,
  leftMirror: mirror,
  rightMirror: mirror,
});

Object.entries(car.shape).forEach(([key, sub]) => {
    console.log(`${key}: ${sub.label}`);
});

Run with Codesandbox.io

But this isn't really possible unfortunately.

@scotttrinh
Copy link
Collaborator

I think it makes sense for Zod to not store arbitrary metadata as it increases the complexity of what Zod is doing (what happens when you transform? etc) and moving that out into the consuming application (inverting control if you will) is usually a more scalable approach for libraries. If maintaining your own map wrapper structure every time feels too heavy you could look into making a library that uses zod under the hood for data parsing but adds additional functionality like this. If you think the general case is general enough for many use cases, we'd be happy to link to it as a part of the Zod ecosystem!

@magJ
Copy link

magJ commented Feb 17, 2023

Since it seems unlikely that this sort of functionality is going to be added to zod any time soon.
For those that really want such a feature, it's pretty easy to hack in:

declare module 'zod' {
  interface ZodType {
    metadata(): Record<string, any>
    associateMetadata(meta: Record<string, any>): this
  }
}

ZodType.prototype.metadata = function () {
  return this._def.meta
}

ZodType.prototype.associateMetadata = function (meta: Record<string, any>) {
  const This = (this as any).constructor
  return new This({
    ...this._def,
    meta,
  })
}

const obj = z.object({}).associateMetadata({ label: 'hello' })
console.log(obj.metadata().label)

All the usual disclaimers about how modifying third party code prototypes is bad.


On the topic of, if such a feature should exist in zod, I think it's worth while.
As already mentioned, some libraries are already using description for the same purposes.

Wrapping zod doesn't seem like a workable solution for common use cases.

Similar to other's use case, in my case, the zod schema describes an API contract.
API's can take fairly complex objects, I want to add information to properties beyond a simple description, for example:

  • A human friendly label for the field.
  • Describe if a field is related to another, some fields might be optional only if another field is present.
  • The kind of form UI component to use to edit the field, some string fields might be best with a small text input, others might need a big textarea or rich text editor.

It doesn't make sense for zod to have first party support for those specific use cases.

I could probably just encode this stuff into a json object and serialize it into the description, but that seems pretty lame.

@valerii15298
Copy link

Instead of using zod internal api to hack into its core I ended up just using decribe() + JSON.parse/JSON.stringify:

z.string().describe(JSON.stringify({metadata: “I ❤️ zod”}))

And then using JSON.parse to extract the data.

Works like a charm!!! 😘

@TonyGravagno
Copy link

I cringe at the idea of hacking in a prototype footgun. I think wrapping Zod is elegant, but since I already have it surfaced all over the place in an app, I don't want to go change that - maybe later.

So the suggestion by @valerii15298 seems to be the next elegant way to do this.

In my app I want schema to provide the indicator of whether a field is rendered as a single-line text control or a multiline textarea. I know this outside the scope of Zod and that I'm piggy-backing functionality, but the schema is already in the same place as form fields, so I'm not seeing a significant downside to this.

I already have the label for fields in the description as follows:

export type SchemaShapeType = { // this ensures ZodObjects only have valid fields
  [key in keyof typeof FieldNameKeys]: z.ZodTypeAny
}
export const schemaShape : SchemaShapeType = {
  id: z.number().describe(PersonText.Labels.id), // labels are in a separate file/type with same key
  first_name: z.string().nullable().describe(PersonText.Labels.first_name),
  birth_date: z.date().nullable().describe(PersonText.Labels.birth_date),
}

Now I just added some JSON and I don't need to change any form or context components. I can get the metadata from the schema when components are being rendered.

export type SchemaMetaType = {
  [key in keyof typeof FieldNameKeys]: any // might strongly type this later
}
export const schemaMeta: SchemaMetaType = { // no-hassle code here
  id: JSON.stringify({
    label: PersonText.Labels.id,
    multiline: false,                //  meta objects are easily extensible (OK, sloppy)
  }),
  first_name: JSON.stringify({
    label: PersonText.Labels.first_name,
    multiline: true,
  }),
  birth_date: JSON.stringify({
    label: PersonText.Labels.birth_date,
  }),
}
export const schemaShape: SchemaShapeType = {
  id: z.number().describe(schemaMeta.id), // trivial change to describe
  first_name: z.string().nullable().describe(schemaMeta.first_name),
  birth_date: z.date().nullable().describe(schemaMeta.birth_date),
}

Finally, in rendering components:

  const meta = JSON.parse( fieldSchema.description )
  const label : string = meta['label'] ?? ''
  otherProps.multiline = meta['multiline'] ?? false

This is a great solution without any effort by Colin and without any fuss in existing code, except where fieldSchema.description is already assumed to be a string. 😉 Yes, later I will probably abstract out UI configuration so that it's not mixed in with data schema, and at that time I might wrap Zod too and modify related code to pull Zod and UI detail as separate entities. For now, this ain't bad.

Thanks for the discussion folks. (Thanks for not closing these threads, Colin.)

HTH

RobinTail added a commit to RobinTail/express-zod-api that referenced this issue Jan 10, 2024
I was dreaming about it.
Unfortunately `zod` does not provide any way to make a custom or branded
or third-party schema that can be identified as one programmatically.
Developer of `zod` also does not want to make any method to store
metadata in schemas, instead, recommends to wrap schemas in some other
structures, which is not suitable for the purposes of `express-zod-api`.
There are many small inconvenient things in making custom schema
classes, that I'd like to replace into native methods, and use
`withMeta` wrapper for storing proprietary identifier, so the generators
and walkers could still handle it.

Related issues:

```
colinhacks/zod#1718
colinhacks/zod#2413
colinhacks/zod#273
colinhacks/zod#71
colinhacks/zod#37
```

PR I've been waiting for months to merged (programmatically
distinguishable branding):

```
colinhacks/zod#2860
```
@jedwards1211
Copy link

Here's how I accomplish this: #76 (comment)

@KurtGokhan
Copy link

I don't understand why this is regarded as an anti-pattern. If you look at it from that perspective, even the description or error messages are meta data and they should be anti-pattern as well. It is no coincidence some people suggest using description for storing metadata.

As for how it should work, it should work exactly like how description works, but allow objects instead of strings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

10 participants