fabian-hiller / valibot

The modular and type safe schema library for validating structural data 🤖
https://valibot.dev
MIT License
6.32k stars 204 forks source link

Enhance Support for Readonly Schemas #896

Closed JacKyDev closed 3 weeks ago

JacKyDev commented 4 weeks ago

Hi, I have a question about the schema and readonly.

The action serves the exact purpose. The problem is the need to write it for every property. I want to mention again that I think it's great that this exists.

In the application I'm working on, I want to ensure that data can only be modified by a dedicated process, and all consumers receive a readonly dataset. I also know from the modular approach that you have to be very careful about simply adding things.

I tried to implement it using a DeepReadonly from typing, but the schema didn't want to work with it, which wasn't unexpected. However, this is basically what I'm trying to achieve. I'm not really aiming to freeze objects but rather to improve the DX.

Additionally, I didn't find anything that valibot offers in this regard.

Now, to my key points :)

1: Is there already a solution that I'm just not finding?

2: Is there, or would it be an idea, to have a function in the generics that can specify that only schemas of type readonly are allowed?

Example here:

const getReadonlySchema = <TSchema extends ReadonlyBaseSchema>(schema: TSchema){
  // do anything
}

Then the developer would still have to define it for each one, but at least they would be blessed with the feedback.

3: Is there, or would it be an idea, to have a function similar to the pick function that takes a schema and converts it into a readonly schema?

Example here:

import * as v from 'valibot';

const Schema = v.object({
  email: v.pipe(v.string(), v.email()),
  password: v.pipe(v.string(), v.minLength(8)),
});

// any prop is readonly now
const readonlySchema = v.readonly(Schema);

// or i think better
const readonlySchema = v.deepReadonly(Schema);

I included both examples because I believe readonly would already be somewhat reserved by the action, and deepReadonly would better match the term used in the TypeScript world.

4: Is there, or would it be an idea, to specify something like this in the configuration of parse?


import * as v from 'valibot';

const Schema = v.object({
  email: v.pipe(v.string(), v.email()),
  password: v.pipe(v.string(), v.minLength(8)),
});

const result = v.safeParse(Schema, {
  email: 'jane@example.com',
  password: '12345678',
}, {
  readonly: true
  // or
  deepReadonly: true
});

console.log(result);

Then the schema would generally remain open, but the parse would produce readonly output. However, I imagine that could be quite difficult to implement.

I hope I don't sound completely crazy, and that the comments make sense. Sorry for the amount of text.

As a final note... I believe that one or the other mechanism would definitely be something that could help other libraries that want to use Valibot, especially when it comes to Flux solutions or other single point of truth implementations.

I'm more than ready to answer any questions and provide feedback.

fabian-hiller commented 4 weeks ago

We have to distinguish between the schema object (what a schema function returns) and the parsing output. Valibot's schema objects are already readonly by default. So there is nothing extra to do on your side. Parsing output is readonly if you use our readonly action in the pipe of a schema. See this API reference for more details and examples: https://valibot.dev/api/readonly/

JacKyDev commented 4 weeks ago

I understood that so far. My question is rather whether it's possible to define a schema without readonly and then make this schema readonly afterwards, following the principle:

import * as v from 'valibot';

const Schema = v.object({
  entry: v.object({
    entry2: v.object({
      ...
    })
  })
});

const ReadonlySchema = deepReadonly(Schema);

The result would be that all entries and subentries would then have readonly, making both the schema and the output marked as readonly in TypeScript.

The current action does this only based on the element specified by the developer. My question is whether a function exists or could exist that does this as well, so that libraries can automatically make an external schema readonly.

So instead of:

import * as v from 'valibot';

const Schema = v.pipe(v.object({
  entry: v.pipe(v.object({
    entry2: v.pipe(v.object({
      ...
    }), v.readonly())
  }), v.readonly())
}), v.readonly());

Or alternatively, that you can specify in the generic that only readonly schemas are allowed.


const anyMethod = <TSchema extends ReadonlyBaseSchema>(schema: TSchema){
  ...
}

// error in typescript Schema is not of type ReadonlyBaseSchema
anyMethod(Schema)

// Works all is fine
anyMethod(ReadonlySchema)

This would inform the schema creator via TypeScript to make everything readonly, otherwise it won't work.

I'll try again to illustrate roughly what I mean with the two mentioned examples using a small sample:

Example 1 with deepReadonly:


// Method to create as example a Store with a Schema.
const store = <TSchema extends BaseSchema<unknown, unknown, BaseIssue<unknown>>>(schema: TSchema){
  const observers = [];
  return {
    dispatch: (value: InferOutput<TSchema>) => {
        // schema converts into readonly schema validation
        // value returns any given prop as readonly
        const result = safeParse(deepReadonly(schema), value);
        if(result.success){
          observers.forEach(observer => observer(result.output));
        }
    }
    observe: (observer) => {
      observers.push(observer);
    }
  }
}

const productStore = store(object({
  name: string(),
  price: object({
    currencyIso: string(),
    value: number()
  })
}))

productStore.observe((value) => {

  // typescript mark error for currencyIso as Readonly Props
  value.price.currencyIso = "Huhu"
});

By using the function, the incorrectly defined schema is converted into a readonly, making the output readonly in TypeScript.

Example 2 with the type:


// Method to create as example a Store with a Schema.
// Name of extends Type is a Sample
// This ReadonlyBaseSchema expect only Readonly Schemas 
const store = <TSchema extends ReadonlyBaseSchema<unknown, unknown, BaseIssue<unknown>>>(schema: TSchema){
  const observers = [];
  return {
    dispatch: (value: InferOutput<TSchema>) => {
        // schema converts into readonly schema validation
        // value returns any given prop as readonly
        const result = safeParse(schema, value);
        if(result.success){
          observers.forEach(observer => observer(result.output));
        }
    }
    observe: (observer) => {
      observers.push(observer);
    }
  }
}

// Typescript error while not all props are readonly
const productStore = store(object({
  name: string(),
  price: object({
    currencyIso: string(),
    value: pipe(number(), readonly())
  })
}))

productStore.observe((value) => {

  // typescript mark error for currencyIso as Readonly Props
  value.price.currencyIso = "Huhu"
});

I hope this explains my idea better. The goal is to determine whether the schema is fully readonly or to make it so if it isn't.

Currently, this is only possible by applying readonly() to each schema or each property within the schema.


I have solved this in an inelegant way by using a DeepReadonly in TypeScript and defining a ts-ignore because I can't guarantee that readonly is used for every property in the schema.

type DeepReadonly<T> = Readonly<{
    [K in keyof T]:
    T[K] extends (number | string | symbol) ? Readonly<T[K]>
        : T[K] extends Array<infer A> ? Readonly<Array<DeepReadonly<A>>>
            : DeepReadonly<T[K]>;
}>

type Event<TSchema extends BaseSchema<unknown, unknown, BaseIssue<unknown>>> = DeepReadonly<{
  value: InferOutput<TSchema>
}>

// then in dispatch
const result = safeParse(schema, newValue);
if(result.success){
  observer({
    // @ts-ignore while schema could not guarantee readonly
    value: result.output 
  })
}
fabian-hiller commented 3 weeks ago

My question is rather whether it's possible to define a schema without readonly and then make this schema readonly afterwards

Yes, this works with pipe and readonly. But we don't have a deepReadonly action at the moment.

import * as v from 'valibot';

const ArraySchema = v.array(v.string());

const ReadonlyArraySchema = v.pipe(ArraySchema, v.readonly());

Or alternatively, that you can specify in the generic that only readonly schemas are allowed.

Unfortunately, I am not sure if this is technically possible with our current implementation, but you could recursively check at runtime if every schema contains a pipe with a readonly action and throw an error if not.


I would change your workaround to:

import { DeepReadonly } from "ts-essentials";
import * as v from 'valibot';

const Schema = v.object({ foo: v.string() });

const output = v.parse(Schema, ['a', 'b', 'c']) as DeepReadonly<
  v.InferOutput<typeof Schema>
>;
JacKyDev commented 3 weeks ago

Yes, you're right. That's something one can live with much better :) Thank you very much.