Flow: `$DeepReadOnly` for nested object types

Created on 19 Feb 2018  路  24Comments  路  Source: facebook/flow

$ReadOnly doesn't recursively apply invariance to nested object types, and there doesn't seem to be any way to do this besides making a separate copy of obj A that is invariant. I still want the variant A around in case I want to mutate it in certain contexts.

It would be nice to have some way to use an object as if it's fully, deeply invariant without needing to declare a separate variant and invariant type for it.
try flow

// @flow
type A = {|
  nested: {
    [id: string]: number
    }
|}

const d: $ReadOnly<A> = {
  nested: {
    a: 123
  }
}

// No errors, since `nested` is not made invariant
d.nested.a = 2
d.nested.b = 222

// Instead we have to make a separate type for read-only

type InvariantA = $ReadOnly<{|
  nested: $ReadOnly<{ // this inner $ReadOnly is necessary to get errors below
    [id: string]: number
  }>
|}>

const dInv: InvariantA = {
  nested: {
    a: 123
  }
}

// the errors I want
dInv.nested.a = 4
dInv.nested.b = 77

Desired:

// Create a deeply invariant object without a separate type
const dInv: $DeepReadOnly<A> = { nested: { a: 123 } }

Related to #5369

Also the "reverse $ReadOnly #5687 would be another approach to the issue of needing a separate variant and invariant type of the same object type, when you want to mutate it. So instead of having $DeepReadOnly and applying it to your variant type, you could have some kind of $DeepReverseReadOnly that you apply to your invariant type. However with $DeepReverseReadOnly, developers would need to include a bunch of $ReadOnlys inside their object type declarations which is cumbersome and can introduce errors.

destructors feature request

Most helpful comment

It's a new year, any feedback? This would be fantastic to have for a redux store structure so that all edits are forced to be spreads...

All 24 comments

You can check out this comment to have a potential solution, until Flow adds this feature.

@mrkev this is a feature request as well

In my trying to understand what this code does, I ended up with this other version:

type PrimitiveValue = boolean | number | string;

type PrimitiveValueArray = Array<boolean> | Array<number> | Array<string>;

type PrimitiveNonValue = null | void;

type PrimitiveNonValueArray = Array<null> | Array<void>;

type Primitive = PrimitiveNonValue | PrimitiveValue;

type PrimitiveArray = PrimitiveValueArray | PrimitiveNonValueArray;

type ReadOnlyArrayFrom<T: PrimitiveArray> = $ReadOnlyArray<$ElementType<T, number>>;

type DeepReadOnly<T0: Object> = $ReadOnly<
  $ObjMap<
    T0,
    // prettier-ignore
    & (<T1: Primitive | Function>(T1) => T1)
    & (<T1: Object>(T1) => DeepReadOnly<T1>)
    & (<T1: PrimitiveArray>(T1) => ReadOnlyArrayFrom<T1>)
    & (<T1: Array<Object>>(T1) => DeepReadOnlyArrayFrom<T1>)
  >
>;

type DeepReadOnlyArray<T: Object> = $ReadOnlyArray<DeepReadOnly<T>>;

type DeepReadOnlyArrayFrom<T: Array<Object>> = DeepReadOnlyArray<
  $ElementType<T, number>
>;

Was hoping you two maybe able to help with a union type issue I'm running into. I tried using the solution by @mgtitimoli with a union type and it fails. An example code of this is:

type A_NullSuccessful = { foo: number, bar: null };
type A_ObjectSuccessful = { foo: number, bar: { prop1: number } };
type A_ErrorsButShouldNot = { foo: number, bar: { prop1: number } | null };

const a: DeepReadOnly<A_NullSuccessful> = {foo: 10, bar: null };
const b: DeepReadOnly<A_ObjectSuccessful> = {foo: 10, bar: { prop1: 1 }};
// The following error's but shouldn't
const c: DeepReadOnly<A_ErrorsButShouldNot> = {foo: 10, bar: null };
const d: DeepReadOnly<A_ErrorsButShouldNot> = {foo: 10, bar: { prop1: 1 }};

Both of you are clearly much better with typing than I am, is there a way to break up the typing of the union?

Hi @rickyp-uber,

I was having a look to your example and the issue happens because the union doesn't match any of the types expressed in the set of functions used to map, so it tries to use the last one and it fails because that union is clearly not an Array<Object>.

I haven't tested it deeply, but I belive that adding a "match all the others" function to the intersection should do the work, so it will be as follows:

type PrimitiveValue = boolean | number | string;

type PrimitiveValueArray = Array<boolean> | Array<number> | Array<string>;

type PrimitiveNonValue = null | void;

type PrimitiveNonValueArray = Array<null> | Array<void>;

type Primitive = PrimitiveNonValue | PrimitiveValue;

type PrimitiveArray = PrimitiveValueArray | PrimitiveNonValueArray;

type ReadOnlyArrayFrom<T: PrimitiveArray> = $ReadOnlyArray<$ElementType<T, number>>;

type DeepReadOnly<T0: Object> = $ReadOnly<
  $ObjMap<
    T0,
    // prettier-ignore
    & (<T1: Primitive | Function>(T1) => T1)
    & (<T1: Object>(T1) => DeepReadOnly<T1>)
    & (<T1: PrimitiveArray>(T1) => ReadOnlyArrayFrom<T1>)
    & (<T1: Array<Object>>(T1) => DeepReadOnlyArrayFrom<T1>)
    & (<T1>(T1) => T1) // match all the others  
 >
>;

type DeepReadOnlyArray<T: Object> = $ReadOnlyArray<DeepReadOnly<T>>;

type DeepReadOnlyArrayFrom<T: Array<Object>> = DeepReadOnlyArray<
  $ElementType<T, number>
>;

The problem with this approach is that this new function won't do anything with the argument that receives, so it will be your responsability to apply $DeepReadOnly to each of the values of that problematic type (union) to make them read only. So your example will also need to change as follows:

type A_NullSuccessful = { foo: number, bar: null };
type A_ObjectSuccessful = { foo: number, bar: { prop1: number } };
type A_ErrorsButShouldNot = { foo: number, bar: DeepReadOnly<{ prop1: number }> | null };

const a: DeepReadOnly<A_NullSuccessful> = {foo: 10, bar: null };
const b: DeepReadOnly<A_ObjectSuccessful> = {foo: 10, bar: { prop1: 1 }};
// The following error's but shouldn't
const c: DeepReadOnly<A_ErrorsButShouldNot> = {foo: 10, bar: null };
const d: DeepReadOnly<A_ErrorsButShouldNot> = {foo: 10, bar: { prop1: 1 }};

Hope this help you.

I kept thinking on this while I was having lunch and I came up with this other minimal version that addresses all the cases with the exception of non standard types (unions, intersections, etc) where will be left untouched, so these ones needs to be set up manually, but for all the other cases (object, arrays, and nested), the following utility type makes them deep read only by just wrapping them with DeepReadOnly (it works for arrays and for objects as well!):

// @flow

// Note
// We are using $ReadOnlyArray to refine Array generics to allow them to be used
// also with tuples 

type ArrayValue<T: $ReadOnlyArray<any>> = $ElementType<T, number>;

type ReadOnlyArrayFrom<T: $ReadOnlyArray<any>> = $ReadOnlyArray<ArrayValue<T>>;

type ToReadOnly =
  & (<T: Object>(T) => DeepReadOnlyObject<T>)
  & (<T: $ReadOnlyArray<any>>(T) => DeepReadOnlyArrayFrom<T>)
  & (<T>(T) => T);

type DeepReadOnlyArrayFrom<T: $ReadOnlyArray<any>> = ReadOnlyArrayFrom<
  $TupleMap<T, ToReadOnly>
>;

type DeepReadOnlyObject<T: Object> = $ReadOnly<$ObjMap<T, ToReadOnly>>;

// We need to do it this way and not use $Call<ToReadOnly, T> as in flow < 0.72
// overloaded functions were only correctly choosen when passing them to map utility
// types ($TupleMap or $ObjMap)
type DeepReadOnly<T> = ArrayValue<$TupleMap<[T], ToReadOnly>>;

Enjoy :smiley: !

Okay, I think I found a solution that isn't elegant but might help with this. It works up to N depth (were you can add more to N if you'd like).

````js
// @flow

declare function readOnly5(number): number;
declare function readOnly5(string): string;
declare function readOnly5(boolean): boolean;
declare function readOnly5(void): void;
declare function readOnly5(null): null;
declare function readOnly5>(T): $ReadOnlyArray;
declare function readOnly5(T): $ReadOnly;

declare function readOnly4(number): number;
declare function readOnly4(string): string;
declare function readOnly4(boolean): boolean;
declare function readOnly4(void): void;
declare function readOnly4(null): null;
declare function readOnly4, R: $Call>(T): $ReadOnlyArray;
declare function readOnly4(T): $ReadOnly<$ObjMap>;

declare function readOnly3(number): number;
declare function readOnly3(string): string;
declare function readOnly3(boolean): boolean;
declare function readOnly3(void): void;
declare function readOnly3(null): null;
declare function readOnly3, R: $Call>(T): $ReadOnlyArray;
declare function readOnly3(T): $ReadOnly<$ObjMap>;

declare function readOnly2(number): number;
declare function readOnly2(string): string;
declare function readOnly2(boolean): boolean;
declare function readOnly2(void): void;
declare function readOnly2(null): null;
declare function readOnly2, R: $Call>(T): $ReadOnlyArray;
declare function readOnly2(T): $ReadOnly<$ObjMap>;

declare function readOnly1(number): number;
declare function readOnly1(string): string;
declare function readOnly1(boolean): boolean;
declare function readOnly1(void): void;
declare function readOnly1(null): null;
declare function readOnly2, R: $Call>(T): $ReadOnlyArray;
declare function readOnly1(T): $ReadOnly<$ObjMap>;

declare function readOnly(number): number;
declare function readOnly(string): string;
declare function readOnly(boolean): boolean;
declare function readOnly(void): void;
declare function readOnly(null): null;
declare function readOnly, R: $Call>(T): $ReadOnlyArray;
declare function readOnly(T): $ReadOnly<$ObjMap>;

function test1() {
declare var a: {b: string} | null;
const readOnlyA = readOnly(a);
// $ExpectError
readOnlyA.b;
if (readOnlyA !== null) {
readOnlyA.b;
// $ExpectError
readOnlyA.b = 'b';
// $ExpectError
readOnlyA = null;
}
}

function test2() {
declare var a: {b: {c: string | null}} | null;
if (a) {
a.b;
}
const readOnlyA = readOnly(a);
if (readOnlyA !== null) {
readOnlyA.b;
// $ExpectError
readOnlyA.b = {c: 'wow'};
// $ExpectError
readOnlyA.b = null;
}
}

function test3() {
declare var a: {b: {c: {d: number[]}}} | null;
if (a) {
a.b;
}
const readOnlyA = readOnly(a);
if (readOnlyA !== null) {
const b = readOnlyA.b;
// $ExpectError
readOnlyA.b.c.d.push(10);
const d: number = b.c.d[0];
}
}
````

hm, what would you do this way @vicapow that is not a "type only" solution because you are calling readOnly function to get a read only type, and not the way I previously wrote which it only requires you to wrap the type you want to convert it to read only with the ReadOnly type?

That鈥檚 correct. It鈥檚 not a complete type only solution but maybe it can inspire one. The new idea being supporting this to N instead of infinite.

hi! maybe this implementation could fit: https://github.com/lttb/flown/tree/master/src/Frozen

for example, it works well here: https://github.com/lttb/typed-actions/blob/master/src/tests/reducers.js#L60

and you can try it here

Hi @lttb,

The implementation you shared it's very similar to the one I wrote here, with the only difference that it doesn't support arrays (it only supports objects), and you are applying $Exact which I believe it goes outside of the scope of read only.

One alternative to support arrays would be to create a type that uses $Call with InnerFrozen, and if you go into this direction you would probably end up in what I wrote before :wink:.

hi @mgtitimoli, that implementation is more about deep Object.freeze (because of specific needs), so that's why it's exact and Frozen 馃槃. But you can use InnerFrozen for arrays as well: $Call<InnerFrozen, [{x: number}, {a: {b: {c: string}}}]>, for example (try here).

UPD. ah, sorry, I did not notice that this is exactly what you said 馃槂 so yeah, thank you :)

Actually it's still super useful to call out because often times you'll want objects to be ReadOnly and Exact (It's actually the situation I'm using DeepReadOnly for myself). Knowing that this is a requirement for the feature would hopefully mean it gets implemented and tested with this feature.

Would anyone from the Flow team be willing to provide context? Is it something FB wants to implement but hasn't had the time? Or is this a feature that should intentionally be left out of Flow?

Hello again,

Friendly bump. Could someone from the core team provide context on this?

cc: @gabelevi, @jbrown215, @samwgoldman, @nmot

It's a new year, any feedback? This would be fantastic to have for a redux store structure so that all edits are forced to be spreads...

Bump

Sorry about the delay.

Internally we use a type defined as follows:

type DeepReadOnlyFn = (<T>(Array<T>) => $ReadOnlyArray<$DeepReadOnly<T>>) &
  (<T: {}>(T) => $ReadOnly<$ObjMap<T, DeepReadOnlyFn>>) &
  (<T>(T) => T);

type $DeepReadOnly<T> = $Call<DeepReadOnlyFn, T>;

@dsainati1 this should error but doesn't:

type tuple = [string, number];
declare var x: $DeepReadOnly<tuple>;
x[0] = 'wat'; // should error

As written the type only applies to arrays and objects. Apologies if this doesn't cover all your use cases.

Right, that's why a flow-native (ocaml) $DeepReadOnly utility type is desired. Because a user-land defined type cannot cover all cases.

@mgtitimoli unfortunately your simplified version doesn't work as well as your original one, Flow fails to catch some errors it should with the simplified version. Bravo on the original version though, I'm surprised at how well it seems to work.

In a lot of complicated type mapping cases like this, Flow seems to just lose track of the types or give up and treat a lot of nested types as any. I've seen TypeScript fail in similar ways. Over time I've become more and more wary of using $ObjMap and function intersection types 馃槙

@mgtitimoli Also now that Object is equivalent to any, it's probably better to replace it with {} in your original types, though surprisingly it seems to work anyway since $ObjMap appears to constrain the input type parameter.

Was this page helpful?
0 / 5 - 0 ratings