valibot
valibot copied to clipboard
[Proposal] Add `ReadonlyOutput<T>` and `parseReadonly()` APIs
I want to treat the objects I parsed with valibot as immutable. Right now, I do this:
import { DeepReadonly } from "ts-essentials";
const InstantSchema = transform(string(), (s) => Temporal.Instant.from(s));
const FooSchema = object({
createdAt: InstantSchema,
x: string(),
y: number(),
z: object({
a: boolean(),
}),
});
type Foo = DeepReadonly<Output<typeof FooSchema>>;
// type Foo1 = {
// readonly createdAt: {
// readonly epochSeconds: number;
// readonly epochMilliseconds: number;
// readonly epochMicroseconds: bigint;
// readonly epochNanoseconds: bigint;
// readonly equals: (other: string | Temporal.Instant) => boolean;
// ... 11 more ...;
// readonly [globalThis.Symbol.toStringTag]: "Temporal.Instant";
// };
// readonly x: string;
// readonly y: number;
// readonly z: {
// readonly a: boolean;
// };
// }
This works (TypeScript gives an error if I try to re-assign a property of Foo). The only annoying thing here is that DeepReadonly also applies to non-valibot types. In my example, Foo.createdAt is no longer a Temporal.Instant, instead it becomes a { readonly epochSeconds: number; ... 16 more ...; }.
Proposal: Export a type ReadonlyOutput<T extends BaseSchema> from valibot. For non-object non-array schemas, this produces the same type as Output<T>. For object and array schemas, this adds a readonly modifier to all schema keys, and maps schema keys to ReadonlyOutput<TEntries[TKey]>.
Here is an example:
type Foo2 = ReadonlyOutput<typeof FooSchema>;
// type Foo2 = {
// readonly createdAt: Temporal.Instant;
// readonly x: string;
// readonly y: number;
// readonly z: {
// readonly a: boolean;
// };
// }
It would also be nice to have a parseReadonly() function. For me personally, readonly-ness is fine to have in TypeScript only, I don't need a runtime Object.freeze(). If you agree that parseReadonly() should not freeze the object, then I think it doesn't need any additional JS, a re-export would be sufficient:
export const parseReadonly = parse as <TSchema extends BaseSchema>(...) => ReadonlyOutput<TSchema>
Let me know if you want help with this change, then I can work on a PR. I'm also happy to discuss other possible solutions or API designs.
What is the use case for read-only in your case? Before implementing ReadonlyInput and ReadonlyOutput, we may should investigate a readonly method that can be wrapped around a schema to call Object.freeze() and change the output type accordingly.
I'm leaning towards a functional programming style in general. Example:
interface ShoppingCartItem {
product: Product;
quantity: number;
}
// NOT how I usually implement things; modifies `shoppingCart` in-place.
function updateShoppingCartItemQuantity1(
shoppingCart: ShoppingCartItem[],
productId: string,
quantity: number
) {
for (const item of shoppingCart) {
if (item.product.id === productId) {
item.quantity = quantity;
}
}
}
// This is how I usually work; treats `shoppingCart` as readonly, returns a new object.
function updateShoppingCartItemQuantity2(
shoppingCart: readonly ShoppingCartItem[],
productId: string,
quantity: number
) {
return shoppingCart.map((item) =>
item.product.id === productId ? { ...item, quantity } : item
);
}
I first saw this approach being advocated by React. It's very useful in React because it allows efficient cache invalidation. Let's say you have a UI component ShoppingCartRow that renders a single shopping cart item. You have 10 items in your shopping cart, so your app renders 10 instances of the ShoppingCartRow component. Now the user updates the quantity of one of the items. What happens?
- Without a caching strategy, your framework can't know which data has changed, so it must re-render all UI components; in this case, all 10
ShoppingCartRowinstances. - React uses object identity as a cache identifier. A
ShoppingCartRowrenders it's UI based on a singleShoppingCartIteminput. If the cache identifier (aka object identity of the inputShoppingCartItem) doesn't change in between two renders, then React knows that the whole object sub-tree hasn't changed, and skips re-rendering theShoppingCartRowcomponent. For example, when usingupdateShoppingCartItemQuantity2(), React will only re-render a singleShoppingCartRow, and skip re-rendering the other 9.
After working like this for a while, I have started using the same pattern in my own classes/data structures/business logic, too. After getting used to it, I find it quite intuitive. I'm even using it on my non-React backend.
Since I declare all my interfaces with readonly types, TypeScript warns me about erroneous mutations, and I don't have to freeze my objects. So far, I never had any problems with it.
I guess the runtime overhead of Object.freeze() is neglibile, and parseReadonly() isn't on the critical path anyways, so if you'd rather freeze, that's also fine by me – I don't have a strong opinion about it. :)
You can get the same DX benefit inside your function by using MaybeReadonly instead of readonly, but this way you do not have to pass readonly values.
type MaybeReadonly<T> = Readonly<T> | T;
function updateShoppingCartItemQuantity2(
shoppingCart: MaybeReadonly<ShoppingCartItem[]>,
productId: string,
quantity: number
) {
return shoppingCart.map((item) =>
item.product.id === productId ? { ...item, quantity } : item
);
}
I noticed that you're a SolidJS community member, so I guess I didn't have to explain the React immutability concept and Redux reducer functions to you. Oops!
Anyways, I re-read your comment. You were actually suggesting something like this, right?
export function readonly<
TSchema extends
| ArraySchema<any>
| ObjectSchema<any>
| RecordSchema<any, any>
| TupleSchema<any>
>(schema: TSchema) {
return transform(schema, (v) => v as Readonly<Output<TSchema>>);
}
export function readonlyAsync<
TSchema extends
| ArraySchema<any>
| ArraySchemaAsync<any>
| ObjectSchema<any>
| ObjectSchemaAsync<any>
| RecordSchema<any, any>
| RecordSchemaAsync<any, any>
| TupleSchema<any>
| TupleSchemaAsync<any>
>(schema: TSchema) {
return transformAsync(schema, (v) => v as Readonly<Output<TSchema>>);
}
const ProductSchema = readonly(
object({
id: string([uuid()]),
createdAt: InstantSchema,
name: string(),
})
);
const ShoppingCartItemSchema = readonly(
object({
product: ProductSchema,
quantity: number(),
})
);
const ShoppingCartSchema = readonly(array(ShoppingCartItemSchema));
I just implemented these readonly()/readonlyAsync() transforms on my side and tested them in my codebase. I think they achieve exactly the behaviour I wanted: The valibot schemas are modified with readonly, but nested properties like product.createdAt retain their original TypeScript type.
I have two minor issues with the DX.
- I need to apply the
readonly()wrapper at every level in my schema; readonly-ness doesn't cascade automatically to sub-schemas. This gets a bit repetitive; on the other hand, it is more flexible and gives more fine-grained control to the developer. pick()/omit()remove the readonly modifier, and I have to apply the wrapper again:readonly(pick(schema, keys)).
Both are minor issues though.
What would be you dream API and the expected behaviour?
What would be you dream API and the expected behaviour?
That's a good question. I have to think more about it. I'll use the userland readonly() function for now and come back to you when I had an idea. Thanks so much for your quick support!
The latest version comes with a readonly action that can be used within a pipeline. Let me know if this solves the issue.
import * as v from 'valibot';
// Mark entire object as readonly
const Schema1 = v.pipe(
v.object({ key1: v.string(), key2: v.number() }),
v.readonly()
);
// Mark specific keys as readonly
const Schema2 = v.object({
key1: v.pipe(v.string(), v.readonly()),
key2: v.number(),
});