1

I created a Mutable type utility which intended to convert immutable structures to mutable ones. This includes readonly xxx[] types as well as the Readonly<T> operators. In my local environment it wasn't working and so I copied it to a TS Playground and to my shock and horror it worked perfectly. Then I noticed that my type util was dependent on a type utility that didn't exist. I'd have thought I'd end up with an any type or something equally as ugly but no ... it worked.

Here's that state of the playground: Working when Broken

The code for Mutate is:

export type Mutable<T> = {
  -readonly [K in keyof T]: IsObject<T[K]> extends true 
    ? Mutable<T[K]> 
    : T[K] extends Readonly<any>
      ? T[K] extends Readonly<infer R>
        ? R
        : never
      : T[K];
};

This code relies on a the IsObject utility which I created but forgot to bring into the playground above. This utility looks like this:

type IsObject<T> = Mutable<T> extends Record<string, any>
  ? T extends <A extends any[]>(...args: A) => any
    ? false  // when a function with props is found, categorize as a function not object
    : true
  : false;

The new playground with previously missing IsObject now included: Playground

Sadly, the first two tests are no longer passing: failing tests

The types passed into these two first tests were similar:

test data

These passed to Mutate<T> produce the same results and some useful conversion has been made but while baz was converted to from a readonly array to a mutable array, the property bar remains with the Readonly<"yes" | "no">

enter image description here

Can anyone help me understand how to remove these Readonly<T> props? Can anyone explain how it all works when I use a non-existing type utility?


Adding to comments ...

example of constraints on use of readonly modifier: limits of modifier

ken
  • 8,763
  • 11
  • 72
  • 133
  • `Readonly<"yes" | "no">` does not really make much sense in the first place. What do you think is the result when TypeScript tries to compute this generic type? – Tobias S. Dec 23 '22 at 02:34
  • The first playground is not actually working, the `IsObject` in underlined, the second playground will work when you remove the ` | readonly string[]` part which is the result you should expect. – ryskajakub Dec 23 '22 at 09:04
  • 1
    The problem is not `Readonly<"yes" | "no">` but that you are expecting `string[] | readonly string[]` as the type of `baz`. Remove `readonly string[]` and the tests pass. The fact that typescript shows you `Readonly<"yes" | "no">` as the type of `bar` field is fine, it ultimately resolves to just `"yes" | "no"` – Alex Chashin Dec 23 '22 at 11:25
  • You don't need `IsObject<~>` you can also do something like this `type Mutable = T extends object ? { -readonly [K in keyof T]: Mutable } : T` – Filly Dec 23 '22 at 12:48
  • @Filly your suggestion fails in the same way mine does – ken Dec 23 '22 at 16:35
  • @TobiasS. not sure why you mean by `Readonly<"yes" | "no">` not making sense. In what way doesn't it? It indicates to developers that this field is one of two literals and that they are not mutable. – ken Dec 23 '22 at 16:36
  • @AlexChashin your conclusion is partially correct ... yes you're right that the test passes but the `Readonly` annotation remains. – ken Dec 23 '22 at 16:40
  • @ryskajakub I think that was the point I was trying to make :) – ken Dec 23 '22 at 16:41
  • @TobiasS. while I know that this annotation is not going to _prevent_ people from mutating the property I do think it creates cognitive disonance in something I'd prefer to be clear – ken Dec 23 '22 at 16:42
  • @ken - strings are always immutable in JavaScript. You maybe want to make the *field* `readonly` like `readonly bar: "yes" | "no"`? If you check the implementation of `Readonly`. It is *not* an intrinsic type. Its just a mapped type which makes all properties `readonly`. When passing a string literal to `Readonly`, one might expect that it just returns the literal type which was passed into it. But the `readonly` part of the mapped type propably confuses the compiler a bit. So it does not really compute the result of this type at all and just leaves it as `Readonly<"yes" | "no">` – Tobias S. Dec 23 '22 at 16:43
  • @TobiasS. i'm referring to a _string_ field on an object. This _can_ be set to being immutable in Javascript with `Object.defineProperty()` and the `readonly` attribute in TS can only be applied to arrays and tuples not to scalars. – ken Dec 23 '22 at 18:31
  • @ken - no, `readonly` works with all kinds of property types: `readonly bar: "yes" | "no"` – Tobias S. Dec 23 '22 at 18:32
  • @TobiasS. I've updated the question at the bottom to show what I'm talking about – ken Dec 23 '22 at 18:35
  • 1
    The `readonly` goes before the property name, as shown in my comment above. – Tobias S. Dec 23 '22 at 18:36
  • Ok, indeed you are right. Still find it odd that I _could_ remove the Readonly when I had an error in my TS utility but not when I removed it but I can live with this. – ken Dec 23 '22 at 18:38
  • Having errors or undefined types leads mostly to undefined behaviour. I am not sure if an investigation into this issue is worth it. – Tobias S. Dec 23 '22 at 18:40
  • No I won't be but it gave me a hope I could remove this artifact as well – ken Dec 23 '22 at 18:50

1 Answers1

2

Having gotten a very useful push in the right direction from @TobaisS. I have now created this type which recursively and cleanly addresses the problem space for instances of the readonly modifier and accepts that for now the Readonly<T> modifier is not an absolute requirement (though it is still on my "nice-to-have" list.

/**
 * Makes a readonly structure mutable
 */
export type Mutable<T> = ExpandRecursively<{
  -readonly [K in keyof T]: T[K] extends {} 
    ? Mutable<T[K]> 
    : T[K] extends readonly (infer R)[]
      ? R[]
      : T[K];
}>;

Where the ExpandResursively<T> utility is used to unwrap the recursion and give a clean type. It looks like this:

export type ExpandRecursively<T> = T extends object
  ? T extends (...args: any[]) => any
    // Functions should be treated like any other non-object value
    // but will/can identify as an object in JS
    ? T
    : { [K in keyof T]: ExpandRecursively<T[K]> }
  : T;

With these two utilities you can take a recursive input like so:

type T = {
  foo: number;
  bar: readonly string[];
  readonly baz: string;
  nested: {
    one: number;
    readonly two: number;
    readonly literal: "foo" | "bar";
  };
};

and convert with Mutable<T> to:

{
  foo: number;
  bar: string[];
  baz: string;
  nested: {
    one: number;
    two: number;
    literal: "foo" | "bar";
  };
}
ken
  • 8,763
  • 11
  • 72
  • 133