immerjs / immer

Create the next immutable state by mutating the current one
https://immerjs.github.io/immer/
MIT License
27.73k stars 850 forks source link

TS errors for draft objects with properties that are indexed using well-known symbols #710

Open justingrant opened 3 years ago

justingrant commented 3 years ago

🐛 Bug Report

Draft<T> objects (at least according to TS) don't bring along any symbol-indexed properties from the original object. Is this expected? I'm not sure if this is only an issue in the TS types, or is the underlying JS draft object also missing its symbol-indexed properties?

Although well-known symbols are usually used with classes, I was able to repro the problem with a plain object too.

I'm using TS 4.1, if it matters.

FWIW, I ran into this because I was trying to call produce() with a deeply-nested object which had a Uint8Array-valued property far down in the tree. When I tried to pass the draft object to another method that expected the original type, TS complained that [Symbol.iterator] and [Symbol.toStringTag] properties that were required on Uint8Array but missing on the Draft<T> object. I'm not trying to mutate the array (that'd be #696); I'm just expecting a deep clone but (at least from TS's perspective) the symbol properties are missing.

Link to repro

import produce, { Draft } from 'immer';

const foo = {
  a() { return 42; },
  get [Symbol.toStringTag]() { return 'foo'; }
}

type Foo = typeof foo;

function withFoo(f: Foo) {
  return f[Symbol.toStringTag];
}

const ok = foo[Symbol.toStringTag];  // no error
const ok2 = withFoo(foo);  // no error

const producer = produce((draft: Draft<Foo>) => {
  draft.a();  // no error
  withFoo(draft);
  /*      ^^^^^
    Argument of type 'WritableDraft<{ a(): number; readonly [Symbol.toStringTag]: string; }>' 
    is not assignable to parameter of type '{ a(): number; readonly [Symbol.toStringTag]: string; }'.
    Property '[Symbol.toStringTag]' is missing in type 'WritableDraft<{ a(): number; 
    readonly [Symbol.toStringTag]: string; }>' but required in type 
    '{ a(): number; readonly [Symbol.toStringTag]: string; }'. (2345)
  */

  const fail = draft[Symbol.toStringTag];
  /*           ^^^^^^^^^^^^^^^^^^^^^^^^^
     Element implicitly has an 'any' type because expression of type 'symbol' can't be used
     to index type 'WritableDraft<{ a(): number; readonly [Symbol.toStringTag]: string; }>'.(7053)
  */
});

https://www.typescriptlang.org/play?#code/JYWwDg9gTgLgBGKEAmBXAxgUwDRwN5wAiUAhgGbwC+cZSIcA5KCJlAwNwBQn6EAdgGd4ZCBDgBefJzhwSACgCU+OFEwxUUPnAAsAJnZxK2aXADmauAG0AygE8QAIwgAbAHQwI1mFGB9TAFRJTAF1FZVV1TUYRCA5DTkpuGFswTDgAMVEJOGTUiDIaUS5OMlQ+dBhgfjgAd2AYAAtMiDkyAC4M0SU8EwiNLTIbeyc3Dy8fP0CQrkSefiE4CABrbJihxxd3T29fAKDggzgAeiO4PjFWJCg5wXhl3Wy6xubWrsOTs4uoK+5eW4QkGgsFBsogUBhMHI5MhSBQOsRyDAADzNAB8SnEqKkMhhiNc8gU71O5zgl2gJieTVE0NhMEJJiOACoZCy4AA9DkckwyACCUFMqBYfDuBVyaQYAHUfDASA5nJgERQkQQCR0+IKHKwDKoSMh+M5bFY7BtRtsJnsQh0hOaDJRUQw4Ny4MABJ94CQBAJgKY+LL5TkxGASKQWDBWItRSlxSrFGqNVqVJhdfrDesRltxrspsErTs-LaGK4nQAFJCpWCGhhpzZjPMW4IOl1wEAur1+Z1aMWMKX1P0K2nK2Sxs7xqAGJ06vV8A1G4Y1s1Z-a5m2Ge1wByoeCqACOqGAqmQHZyUcdrIYMYUcccCcnKdnJozdezy92BdccDkugAzNoAKwKExGSObgZD+BYyBIYBnGyXEKGrU1M0mfYuBkJlWXQmROSw7CcNwp04AAUXlIV4FAMBnGAdB6hnBoPVkLQGBIPhbAdLtNXQEhUAENJMAAD0QTBPSqLR8mPVJGAEOdnAdDi+AYeBNTgLjMGQfCPA7ZA+LE8UexlOV+0RQdVRHa8x0TZNp1TY101rc1nzga1X1XQs5AAdgABl-L8AJkICEnpIA

LMK if you want me to turn the TS playground code above into a CodeSandbox example; happy to do so.

To Reproduce

Steps to reproduce the behavior: 1) Create a producer that accepts a TS type that has a well-known symbol property 2) Try to read that property or call a function that accepts a parameter of T from Draft<T>. Note that mutation is not required-- this problem affects read-only access.

Observed behavior

TS errors as noted above.

Expected behavior

No TS errors.

Environment

We only accept bug reports against the latest Immer version.

justingrant commented 3 years ago

Apparently (see https://github.com/microsoft/TypeScript/issues/37182) the root cause is that TS doesn't include well-known symbols in keyof results. So any transformation of object types using keyof will lose symbols and cause this bug.

I tried to see if this could be fixed by patching types-external.ts. The changes below fixed the problem in my code:

/**
 * Symbols are not (in TS 4.1) included in [K in keyof T] expressions. The type
 * below adds symbols back.
 */
type WellKnownSymbols<T> = (T extends {[Symbol.asyncIterator]: infer V}
    ? {[Symbol.asyncIterator]: V}
    : {}) &
    (T extends {[Symbol.hasInstance]: infer V} ? {[Symbol.hasInstance]: V} : {}) &
    (T extends {[Symbol.isConcatSpreadable]: infer V}
        ? {[Symbol.isConcatSpreadable]: V}
        : {}) &
    (T extends {[Symbol.iterator]: infer V} ? {[Symbol.iterator]: V} : {}) &
    (T extends {[Symbol.match]: infer V} ? {[Symbol.match]: V} : {}) &
    // @ts-ignore matchAll may be used in later lib versions
    (T extends {[Symbol.matchAll]: infer V} ? {[Symbol.matchAll]: V} : {}) &
    (T extends {[Symbol.replace]: infer V} ? {[Symbol.replace]: V} : {}) &
    (T extends {[Symbol.search]: infer V} ? {[Symbol.search]: V} : {}) &
    (T extends {[Symbol.split]: infer V} ? {[Symbol.split]: V} : {}) &
    (T extends {[Symbol.species]: infer V} ? {[Symbol.species]: V} : {}) &
    (T extends {[Symbol.toPrimitive]: infer V} ? {[Symbol.toPrimitive]: V} : {}) &
    (T extends {[Symbol.toStringTag]: infer V} ? {[Symbol.toStringTag]: V} : {}) &
    (T extends {[Symbol.unscopables]: infer V} ? {[Symbol.unscopables]: V} : {})

export type WritableDraft<T> = {-readonly [K in keyof T]: Draft<T[K]>} &
    WellKnownSymbols<T>

Unfortunately, the changes above broke a few tests because spec.ts doesn't consider A & B to be equivalent to A. So I tried to patch spec.ts with similar changes:

/**
 * Symbols are not (as of TS 4.1) included in [K in keyof T] expressions. The
 * type below adds symbols back. Remove this if 
 * https://github.com/microsoft/TypeScript/pull/24738 lands. See also:
 * https://github.com/microsoft/TypeScript/issues/24622 and
 * https://github.com/microsoft/TypeScript/issues/37182#issuecomment-594933808
 */
type WellKnownSymbols<T> = (T extends { [Symbol.asyncIterator]: infer V }
  ? // @ts-ignore asyncIterator requires later lib version than Symbol
  { [Symbol.asyncIterator]: V }
  : {}) &
  (T extends { [Symbol.hasInstance]: infer V } ? { [Symbol.hasInstance]: V } : {}) &
  (T extends { [Symbol.isConcatSpreadable]: infer V }
    ? { [Symbol.isConcatSpreadable]: V }
    : {}) &
  (T extends { [Symbol.iterator]: infer V } ? { [Symbol.iterator]: V } : {}) &
  (T extends { [Symbol.match]: infer V } ? { [Symbol.match]: V } : {}) &
  // @ts-ignore matchAll requires later lib version than Symbol
  (T extends { [Symbol.matchAll]: infer V } ? { [Symbol.matchAll]: V } : {}) &
  (T extends { [Symbol.replace]: infer V } ? { [Symbol.replace]: V } : {}) &
  (T extends { [Symbol.search]: infer V } ? { [Symbol.search]: V } : {}) &
  (T extends { [Symbol.split]: infer V } ? { [Symbol.split]: V } : {}) &
  (T extends { [Symbol.species]: infer V } ? { [Symbol.species]: V } : {}) &
  (T extends { [Symbol.toPrimitive]: infer V } ? { [Symbol.toPrimitive]: V } : {}) &
  (T extends { [Symbol.toStringTag]: infer V } ? { [Symbol.toStringTag]: V } : {}) &
  (T extends { [Symbol.unscopables]: infer V } ? { [Symbol.unscopables]: V } : {});
type WithSymbols<T> = T extends object ? T & WellKnownSymbols<T> : T
// prettier-ignore
export type MergeInsertions<T> = T extends object
  ? { [K in keyof WithSymbols<T>]: MergeInsertions<WithSymbols<T>[K]> }
  : T;
export type TestAlike<X, Y> = TestExact<MergeInsertions<X>, MergeInsertions<Y>>;

export type Test<Left, Right> = IsAny<Left> extends 1
  ? IsAny<Right> extends 1
    ? 1
    : "❌ Left type is 'any' but right type is not"
  : IsAny<Right> extends 1
  ? "❌ Right type is 'any' but left type is not"
  : [Left] extends [Right]
  ? [Right] extends [Left]
    ? Any extends TestAlike<Left, Right> // replaced TestExact with TestAlike
      ? 1
      : "❌ Unexpected or missing 'readonly' property"
    : "❌ Right type is not assignable to left type"
  : "❌ Left type is not assignable to right type";

These spec.ts changes broke Immer tests that used readonly arrays, probably because TS doesn't have a concept of "read-only iterators" so the iterator coming from a readonly array was a writable iterator.

At this point, I'm stumped! It's possible there may not be a perfect solution until TS lands https://github.com/microsoft/TypeScript/pull/24738, although my experiments above suggest that there may be least-bad solutions possible with some tradeoffs.

Any ideas how to fix this without the tradeoffs noted above?

mweststrate commented 3 years ago

Did you try the cast utilities? https://immerjs.github.io/immer/typescript#cast-utilities

rockwotj commented 3 years ago

Did you try the cast utilities? https://immerjs.github.io/immer/typescript#cast-utilities

This doesn't work with those as in the original example above, Draft<Foo>[Symbol.toStringTag] is never. Because Draft and Immutable drop them.