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

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

Open
1 task done
justingrant opened this issue Nov 26, 2020 · 3 comments
Open
1 task done

Comments

@justingrant
Copy link
Contributor

justingrant commented Nov 26, 2020

🐛 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.

  • Immer version:
  • I filed this report against the latest version of Immer
  • [n/a (TS only)] Occurs with setUseProxies(true)
  • [n/a (TS only)] Occurs with setUseProxies(false) (ES5 only)
@justingrant
Copy link
Contributor Author

Apparently (see microsoft/TypeScript#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 microsoft/TypeScript#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
Copy link
Collaborator

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

@rockwotj
Copy link

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants