Skip to content

Support for zod or jsonschema or some kind of UI validation helper #1603

@psankar

Description

@psankar

I have a .proto file as follows:

message SignupRequest {
  string full_name = 1 [(buf.validate.field).string = {
    min_len: 2
    max_len: 100
    // Support Unicode letters, spaces, hyphens, apostrophes, and periods
    // No leading/trailing spaces, no multiple consecutive spaces
    // Allows names in any language/script (Unicode letters)
    pattern: "^[\\p{L}][\\p{L}\\s'\\-\\.]*[\\p{L}]$"
  }];

  string email = 2 [(buf.validate.field).string.email = true];

  string password = 3 [(buf.validate.field).string = {
    min_len: 8
    max_len: 128
  }];

  Region region = 4 [(buf.validate.field).enum = {
    defined_only: true
    not_in: [0] /* REGION_UNSPECIFIED is not allowed */
  }];
}

I generated the _pb.ts files using protoc-gen-es and I am able to use it in a simple form library as below:

<Form
      form={form}
      onFinish={handleSubmit}
      onFinishFailed={onFinishFailed}
      labelCol={{ span: 8 }}
      wrapperCol={{ span: 16 }}
      style={{ maxWidth: 600 }}
      autoComplete="off"
    >
      <Form.Item<FieldType> label="Full Name" name="full_name">
        <Input
          placeholder="John Doe"
          onBlur={(e) => {
            const value = e.target.value;
            const request = create(SignupRequestSchema, { fullName: value });
            const result = validator.validate(SignupRequestSchema, request);
            if (result.kind !== "valid") {
              const errors =
                result.violations
                  ?.filter((v) => v.field[0] === fullNameField)
                  .map((v) => v.message) || [];
              form.setFields([{ name: "full_name", errors }]);
            } else {
              form.setFields([{ name: "full_name", errors: [] }]);
            }
          }}
        />
      </Form.Item>

      <Form.Item<FieldType> label="Email" name="email">
        <Input
          placeholder="[email protected]"
          onBlur={(e) => {
            const value = e.target.value;
            const request = create(SignupRequestSchema, { email: value });
            const result = validator.validate(SignupRequestSchema, request);
            if (result.kind !== "valid") {
              const errors =
                result.violations
                  ?.filter((v) => v.field[0] === emailField)
                  .map((v) => v.message) || [];
              form.setFields([{ name: "email", errors }]);
            } else {
              form.setFields([{ name: "email", errors: [] }]);
            }
          }}
        />
      </Form.Item>

      <Form.Item<FieldType> label="Password" name="password">
        <Input.Password
          placeholder="Enter a strong password"
          onBlur={(e) => {
            const value = e.target.value;
            const request = create(SignupRequestSchema, { password: value });
            const result = validator.validate(SignupRequestSchema, request);
            if (result.kind !== "valid") {
              const errors =
                result.violations
                  ?.filter((v) => v.field[0] === passwordField)
                  .map((v) => v.message) || [];
              form.setFields([{ name: "password", errors }]);
            } else {
              form.setFields([{ name: "password", errors: [] }]);
            }
          }}
        />
      </Form.Item>

      <Form.Item<FieldType> label="Region" name="region">
        <Select
          placeholder="Select a region"
          onBlur={() => {
            const value = form.getFieldValue("region");
            const request = create(SignupRequestSchema, { region: value });
            const result = validator.validate(SignupRequestSchema, request);
            if (result.kind !== "valid") {
              const errors =
                result.violations
                  ?.filter((v) => v.field[0] === regionField)
                  .map((v) => v.message) || [];
              form.setFields([{ name: "region", errors }]);
            } else {
              form.setFields([{ name: "region", errors: [] }]);
            }
          }}
          onChange={(value) => form.setFieldValue("region", value)}
        >
          <Select.Option value={Region.USA}>USA</Select.Option>
          <Select.Option value={Region.EUR}>Europe</Select.Option>
          <Select.Option value={Region.IND}>India</Select.Option>
          <Select.Option value={Region.SGP}>Singapore</Select.Option>
        </Select>
      </Form.Item>

      <Form.Item label={null}>
        <Button type="primary" htmlType="submit" loading={loading}>
          Submit
        </Button>
      </Form.Item>
    </Form>

It is a simple form with just 4 fields, where each field on losing focus (when tab is pressed after entering values there), a validation is performed using the buf generated code.

The tsx above is formatted using the standard prettier tool.

The frustration here is, the validation code is quite verbose, long and occupies a lot more lines than the core UI code. If buf can generate zod or jsonschema or some such, the validation code will become a lot shorter like:

export const signupSchema = z.object({
  full_name: z
    .string()
    .min(2, "Full name must be at least 2 characters")
    .max(100, "Full name must not exceed 100 characters")
    .regex(
      /^[\p{L}][\p{L}\s'\-.]*[\p{L}]$/u,
      "Full name must start and end with a letter, and can only contain letters, spaces, hyphens, apostrophes, and periods"
    ),
  email: z.string().email("Invalid email address"),
  password: z
    .string()
    .min(8, "Password must be at least 8 characters")
    .max(128, "Password must not exceed 128 characters"),
  region: z.nativeEnum(Region).refine((val) => val !== Region.UNSPECIFIED, {
    message: "Please select a region",
  }),
});
 const validateField = (fieldName: keyof SignupFormData, value: any) => {
    try {
      signupSchema.shape[fieldName].parse(value);
      form.setFields([{ name: fieldName, errors: [] }]);
    } catch (error: any) {
      if (error.errors) {
        form.setFields([{ name: fieldName, errors: [error.errors[0].message] }]);
      }
    }
  };
<Form
      form={form}
      onFinish={handleSubmit}
      onFinishFailed={onFinishFailed}
      labelCol={{ span: 8 }}
      wrapperCol={{ span: 16 }}
      style={{ maxWidth: 600 }}
      autoComplete="off"
    >
      <Form.Item<SignupFormData> label="Full Name" name="full_name">
        <Input
          placeholder="John Doe"
          onBlur={(e) => validateField("full_name", e.target.value)}
        />
      </Form.Item>

      <Form.Item<SignupFormData> label="Email" name="email">
        <Input
          placeholder="[email protected]"
          onBlur={(e) => validateField("email", e.target.value)}
        />
      </Form.Item>

      <Form.Item<SignupFormData> label="Password" name="password">
        <Input.Password
          placeholder="Enter a strong password"
          onBlur={(e) => validateField("password", e.target.value)}
        />
      </Form.Item>

      <Form.Item<SignupFormData> label="Region" name="region">
        <Select
          placeholder="Select a region"
          onBlur={() => validateField("region", form.getFieldValue("region"))}
          onChange={(value) => form.setFieldValue("region", value)}
        >
          <Select.Option value={Region.USA}>USA</Select.Option>
          <Select.Option value={Region.EUR}>Europe</Select.Option>
          <Select.Option value={Region.IND}>India</Select.Option>
          <Select.Option value={Region.SGP}>Singapore</Select.Option>
        </Select>
      </Form.Item>

      <Form.Item label={null}>
        <Button type="primary" htmlType="submit" loading={loading}>
          Submit
        </Button>
      </Form.Item>
    </Form>

As you can see, the code is a lot shorter with zod.

I wish connect-es generates zod or jsonschema or some such to make the validations on the client side easier.

The only alternative that I could think of is, to create the zod schema manually from the protobuf, but, that is highly error prone.

If you can recommend any alternative to keep the client side code shorter, I am open to it. I tried creating a common function for the _pb.ts file also, but I could not achieve it without losing on the type safety or across multiple types of widgets.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions