1

I am wondering what's available in typescript and mapped types to create a mapped type that will apply readonly and optional properties as defined in the initial type. Example will be easier to explain.

I've made an example as simple as possible, starting with a type defined as

type PropertyAttributes = {
  readonly optional?: boolean;
  readonly readonly?: boolean;
  readonly value: string; //this would be something else but to simplify example like this
};

type ObjectDefinition = Record<string, PropertyAttributes>

This is the template in a way, from which I would like to create a type. For now I have this solution which works quite well by creating 4 different types and more less combining them.

//Four types corresponding to 4 types of objects
type ReadonlyRequired<O extends ObjectDefinition> = {
  +readonly [K in keyof O as O[K]["readonly"] extends true
    ? O[K]["optional"] extends true
      ? never
      : K
    : K]: O[K];
};

type ReadonlyOptional<O extends ObjectDefinition> = {
  +readonly [K in keyof O as O[K]["readonly"] extends true
    ? O[K]["optional"] extends true
      ? K
      : never
    : never]?: O[K] | undefined;
};

type MutableRequired<O extends ObjectDefinition> = {
  -readonly [K in keyof O as O[K]["readonly"] extends true
    ? never
    : O[K]["optional"] extends true
    ? never
    : K]: O[K];
};

type MutableOptional<O extends ObjectDefinition> = {
  -readonly [K in keyof O as O[K]["readonly"] extends true
    ? never
    : O[K]["optional"] extends true
    ? K
    : never]?: O[K] | undefined;
};
//Expand Util
export type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;

//Put them all together
type Combine<O extends ObjectDefinition> =  Expand<
  ReadonlyRequired<O> &
    ReadonlyOptional<O> &
    MutableRequired<O> &
    MutableOptional<O>
>;

Then finally a type that I think is not relevant to the core of the question but it maps the resulting type to take the object

//simple maps the Object to value preserving keys since homomorphic as I understand
type MapToValue<O extends ObjectDefinition> = {[K in keyof O]: NonNullable<O[K]>["value"]} //need NonNullable for optional types or infered as unknown

type ToObj<O extends ObjectDefinition>=MapToValue<Combine<O>>

This works fine, it's a bit long and the only issue is the order is not preserved because we have to split it out. I'm just wondering if anyone can think of a different way?

Or any even minor improvements you can think of would be awesome.

Here is playground link typescript playground

Thank you

Max
  • 859
  • 5
  • 10
  • Oof, why are you using "mutable" instead of "readonly"? Given that TypeScript considers readonly as a positive attribute and mutable as a negative one (e.g., mutable is `-readonly`), it's kind of upside-down. – jcalz Apr 04 '23 at 18:20
  • 2
    Does [this approach](https://tsplay.dev/mp086m) meet your needs? If so I could write up an answer explaining; if not, what am I missing? – jcalz Apr 04 '23 at 18:23
  • haha I started with readonly, since its a part of a schema tool, I wanted the default to be readonly as I think it is more suited for most web application. And from a usability point of view having an empty but default to true was a bit unusual in my opinion. Might revisit though – Max Apr 04 '23 at 18:24
  • yea as usual bullseye. Did you write typescript ? I'm always impressed by your answers so clean and concise. – Max Apr 04 '23 at 18:27
  • But if you a sec, and for completeness sake, could you please also include what it might look like if it were to be readonly instead of mutable? thanks! – Max Apr 04 '23 at 18:28
  • ** Also I've edited the question to make mutable and optional optional, I should have done that in the first place, it may help with situating the context of the problem. I think it affects only slightly your answer, may have to check the property? – Max Apr 04 '23 at 18:31
  • 1
    Okay, [this version](https://tsplay.dev/mL854m) is how I'd like to answer. Could you [edit] the question to use `readonly` instead of mutable? To align with how TypeScript represents the modifiers, it should be that `readonly` needs to be `true` to be readonly, otherwise (`false` or undefined) it's mutable. If you want your own code to make `readonly` default, that's fine, but that seems to be an added complication you can take offline and not be confusing for future readers. Deal? – jcalz Apr 04 '23 at 18:36
  • Hahah yes perfect, all the other variations can be inferred from that. Thank you – Max Apr 04 '23 at 18:37
  • Actually though, if I change the question to readonly, I would have to change all the snippets. I think it is fine if you're answer changes the proposed approach to readonly? It's just a slightly different approach to solve the same problem. – Max Apr 04 '23 at 18:39
  • You can't change the snippets? I mean I could do it in like a minute if you can't; do you mind if I [edit]? SO is meant for future readers too so I don't want to even bring up `mutable` if I don't have to – jcalz Apr 04 '23 at 18:43
  • Yea I could edit the snippets also sorry, I was just worried I might make them incorrect, I'll try now and update it – Max Apr 04 '23 at 18:44
  • 1
    I've updated, I think the result is slightly different but it should not harm the question in any way, thanks a lot for the input and guidance! – Max Apr 04 '23 at 18:54
  • 1
    Also if you want to add any edits to the question to better contextualize the answer of course feel free, the core of the question anyway wont change much. – Max Apr 04 '23 at 19:02

1 Answers1

1

One approach is to write ToObj as follows:

type ToObj<T extends Record<keyof T, PropertyAttributes>> = {
  -readonly [K in keyof T]-?: (x:
    MaybeOptional<
      MaybeReadonly<
        Record<K, T[K]['value']
        >, T[K]['readonly']
      >, T[K]['optional']
    >) => void }[keyof T] extends ((x: infer I) => void) ?
  { [K in keyof I]: I[K] } : never;

where MaybeOptional<T, B> and MaybeReadonly<T, B> are utility types that either apply Partial or Readonly to an object type T depending on whether or not the boolean-ish B is true:

type MaybeOptional<T, B extends boolean | undefined> =
  B extends true ? Partial<T> : T

type MaybeReadonly<T, B extends boolean | undefined> =
  B extends true ? Readonly<T> : T;

The way ToObj<T> works here is to use a mapped type to walk through each property of T in turn and create a single-property object with the optional or readonly bits set as appropriate. Here, let's break that part out into its own thing:

type ToObjStepOne<T extends Record<keyof T, PropertyAttributes>> = {
  -readonly [K in keyof T]-?:
  MaybeOptional<
    MaybeReadonly<
      Record<K, T[K]['value']
      >, T[K]['readonly']
    >, T[K]['optional']
  > }

You can see how this behaves on an input type:

type S1 = ToObjStepOne<{
  readonlyRequired: { readonly: true; value: "someValue" };
  readonlyOptional: { readonly: true; optional: true; value: "someValue" };
  mutableRequired: { value: "someValue" };
  mutableOptional: { optional: true; value: "someValue" };
}>;
/* type S1 = {
    readonlyRequired: Readonly<Record<"readonlyRequired", "someValue">>;
    readonlyOptional: Partial<Readonly<Record<"readonlyOptional", "someValue">>>;
    mutableRequired: Record<"mutableRequired", "someValue">;
    mutableOptional: Partial<Record<"mutableOptional", "someValue">>;
} */

Then we use the same technique from Transform union type to intersection type where we put each property into a contravariant position so we can infer a single intersection:

type ToObjStepTwo<T extends Record<keyof T, PropertyAttributes>> =
  ToObjStepOne<T> extends infer T1 ?
  { [K in keyof T1]: (x: T1[K]) => void }[keyof T1] extends
  (x: infer I) => void ? I : never
  : never

type S2 = ToObjStepTwo<{⋯}>
/* type S2 = 
  Readonly<Record<"readonlyRequired", "someValue">> & 
  Partial<Readonly<Record<"readonlyOptional", "someValue">>> & 
  Record<"mutableRequired", "someValue"> & 
  Partial<Record<"mutableOptional", "someValue">> 
*/

And you can see that this intersection is essentially the type you want, although it's ugly. At least the properties are in the right order. So if we now do the last little bit to map over the object, it will turn the ugly intersection into a single object type:

type ToObjStepThree<T extends Record<keyof T, PropertyAttributes>> =
  ToObjStepTwo<T> extends infer T2 ?
  { [K in keyof T2]: T2[K] }
  : never

type S3 = ToObjStepThree<{⋯}>
/* type S3 = {
    readonly readonlyRequired: "someValue";
    readonly readonlyOptional?: "someValue" | undefined;
    mutableRequired: "someValue";
    mutableOptional?: "someValue" | undefined;
} */

Let's make sure that's what we get when we run the single-piece definition:

type T1 = ToObj<{
  readonlyRequired: { readonly: true; value: "someValue" };
  readonlyOptional: { readonly: true; optional: true; value: "someValue" };
  mutableRequired: { value: "someValue" };
  mutableOptional: { optional: true; value: "someValue" };
}>;
/* type T1 = {
    readonly readonlyRequired: "someValue";
    readonly readonlyOptional?: "someValue" | undefined;
    mutableRequired: "someValue";
    mutableOptional?: "someValue" | undefined;
} */

Looks good!

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360