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

Readonly fields on inferred type #9

Open
threema-danilo opened this issue Jun 10, 2021 · 10 comments
Open

Readonly fields on inferred type #9

threema-danilo opened this issue Jun 10, 2021 · 10 comments

Comments

@threema-danilo
Copy link

Is there a way to get fields in the inferred type to be marked as readonly?

I'm thinking of something like this:

export const SCHEMA = v.object({
    regular_field: v.string(),
    optional_field: v.string().optional(),
    readonly_field: v.string().readonly(),
});
export type InferredType = v.Infer<typeof SCHEMA>;

..resulting in:

type InferredType = {
    optional_field?: string | undefined;
    regular_field: string;
    readonly readonly_field: string;
}
@jviide
Copy link
Contributor

jviide commented Jun 10, 2021

That's a good idea 👍 I wonder what the API should be, as there are two cases that could be supported:

  1. Making a specific property readonly (like in the example you provided).
  2. Making the whole object/array readonly (readonly [...] and Readonly<{ ... }>).

@threema-danilo
Copy link
Author

Yeah, having a way to make the entire object readonly would be useful too, and would save some repetitive typing.

I'm not sufficiently familiar with the implementation to be able to suggest a good API. For specific properties, using .readonly() (similar to .optional()) would feel natural.

@Geordi7
Copy link

Geordi7 commented Oct 20, 2021

for read only arrays/tuples and objects/records having a .readonly() makes sense as well these should transform the type into a ReadOnlyTuple for example.

@jviide
Copy link
Contributor

jviide commented Oct 31, 2021

After thinking about this for a while... I haven't reached any good conclusion. But I have some thoughts, so prepare for a half-formed braindump 🙂

What is .readonly()?

I'm a bit worried about the fact that "readonly" tends to refer to two slightly different things in TypeScript. Having a .readonly() method could mean these two things:

  • Making all fields of v.object/v.record/v.tuple/v.array readonly (e.g. v.object({ a: ..., b... }).readonly()).
  • Making a specific field of v.object readonly (e.g. v.object({ a: v.number().readonly() })).

Therefore .readonly's meaning would be somewhat overloaded, and the meaning of the following would be ambiguous:

v.object({
  a: v.array(v.number()).readonly()
});

Is the output type now { readonly a: readonly [number] }, { readonly a: [number] } or { a: readonly [number] }? 🙂

One solution for this would, of course, to have different named methods for those different use cases. Like v.array(...).immutable() for example. That'd be pretty descriptive. Maybe.

Helper functions

I'm not fully convinced this even needs to be in the Valita core. .assert, .map and .chain methods are pretty cool and open up interesting possibilities for implementing some functionality as external helper functions. For example, the "make some object fields readonly" can be implemented in the following way:

// Flatten an intersection { a: A } & { b: B } to { a: A, b: B }
type Flatten<V> = { [K in keyof V]: V[K] };

// Create a type predicate that asserts that the objects given fields are readonly.
function ro<O extends Record<string, unknown>, F extends (keyof O)[]>(
  ..._fields: F
) {
  return (
    o: O
  ): o is Flatten<Omit<O, F[number]> & Readonly<Pick<O, F[number]>>> => true;
}

const SCHEMA = v
  .object({
    regular_field: v.string(),
    optional_field: v.string().optional(),
    readonly_field: v.string(),
  })
  .assert(ro("readonly_field"));

type InferredType = v.Infer<typeof SCHEMA>;
// type InferredType = {
//   regular_field: string;
//   optional_field?: string | undefined;
//   readonly readonly_field: string;
// }

A similar solution can be cooked up for making the whole object / array readonly.

Let's get radical

One interesting & bit radical approach would be to just make all Valita output readonly by default. It would have the additional benefit of discouraging people to mutate the output (and therefore accidentally mutating the input too, as Valita passes through the original object if it can get away with it). YMMV, of course 🙂

@threema-danilo
Copy link
Author

I see your point about the ambiguity. Using both readonly (for a single field) and immutable (for an entire object) might be a good approach.

By the way, doesn't .optional() suffer from ambiguity as well? If I use this definition:

v.object({
  a: v.array(v.number()).optional()
});

...will this result in { a?: number[] }, { a: (number | undefined)[] } or { a?: (number | undefined)[] }?

Or even simpler: Does this

v.object({
  a: v.number().optional()
});

...result in { a?: number }, { a: number | undefined } or { a?: number | undefined }?

The helper function isn't bad, but the ergonomics from a user point of view aren't great: Why do I mark optional fields as optional in the definition itself, while I have to use separate assertions to make a field readonly, even though optional and readonly are both core features of TypeScript?

Regarding readonly fields by default: I wouldn't be opposed, but then you'll need a way to mark some fields as mutable, which results in the same discussion 🙂 It's probably something that could be discussed separately.

@marvinhagemeister
Copy link
Contributor

marvinhagemeister commented Nov 1, 2021

No, it's not the same. In TypeScript the optional modifier acts very different compared to readonly. It only applies to properties and you cannot mark, let's say an array, as optional. An array can't be optional, but an object property can. So there is no ambiguity regarding optional.

However, the readonly keyword can be used for two separate purposes:

interface Foo {
  readonly bar: string[]
}

// vs
interface Foo {
  bar: readonly string[]
}

That's where the ambiguity comes from.

@Geordi7
Copy link

Geordi7 commented Feb 14, 2024

@jviide wrote:

Let's get radical

One interesting & bit radical approach would be to just make all Valita output readonly by default. It would have the additional benefit of discouraging people to mutate the output (and therefore accidentally mutating the input too, as Valita passes through the original object if it can get away with it). YMMV, of course 🙂

I totally support this! We don't use readonly mostly due to the inconvenience of writing it everywhere, but it is definitely the preferred semantic choice!

@Geordi7
Copy link

Geordi7 commented Feb 14, 2024

The other thing you can do is manipulate the inferred type

const A = v.object({
    a: v.string(),
    b: v.string(),
});

type ARO = Readonly<v.Infer<typeof A>> // entirely read only

type BRO = ReadonlyProp<'b', v.Infer<typeof A>> // just set prop 'b' to be readonly

// even weirder?
const ARO = A as v.Type<Readonly<v.Infer<typeof A>>>

@dimatakoy
Copy link
Contributor

i like it too, but it really not for every env. For example, vuejs prefers mutability, react immutability, angular mutability(especially with signals), on backend i we can extend initial data (its rare case, but...). So I think, mutable is better due to js nature.

If we really need readonly result we can make a helper or add an option to parse/try methods.

Also, if we care about perfomance, mutable is always better.

@jviide
Copy link
Contributor

jviide commented Feb 26, 2024

The other thing you can do is manipulate the inferred type

const A = v.object({
    a: v.string(),
    b: v.string(),
});

type ARO = Readonly<v.Infer<typeof A>> // entirely read only

type BRO = ReadonlyProp<'b', v.Infer<typeof A>> // just set prop 'b' to be readonly

// even weirder?
const ARO = A as v.Type<Readonly<v.Infer<typeof A>>>

This is the recommended method, and you can encapsulate it into a helper tailored for your use case. A couple of examples:

// Type-level version
function readonly<T extends unknown[] | Record<string, unknown>>(
  v: T,
): Readonly<T> {
  return v as Readonly<T>;
}
v.object({ a: v.number() }).map(readonly);
v.array(v.number()).map(readonly);
v.tuple([v.number()]).map(readonly);
v.record(v.number()).map(readonly);

// Coerce values to frozen objects / arrays
function frozen<T extends unknown[] | Record<string, unknown>>(
  v: T,
): Readonly<T> {
  if (Array.isArray(v)) {
    return Object.freeze([...v]) as unknown as Readonly<T>;
  }
  return Object.freeze({ ...v });
}
v.object({ a: v.number() }).map(frozen);
v.array(v.number()).map(frozen);
v.tuple([v.number()]).map(frozen);
v.record(v.number()).map(frozen);

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

5 participants