unadlib / mutative

Efficient immutable updates, 2-6x faster than naive handcrafted reducer, and more than 10x faster than Immer.
http://mutative.js.org/
MIT License
1.53k stars 16 forks source link

Need a nice handy way to opt return type #12

Closed victordidenko closed 1 year ago

victordidenko commented 1 year ago

Sometimes I need to change type of original object. For example, I want to add new field, or change existing field. Currently it is impossible, either with mutative, and with immer both, as far as I know (maybe I am missing something?).

import { create } from 'mutative'

type S = {
  x: string
}

type N = {
  x: number
}

const x: S = {
  x: "10"
}

// Type 'S' is not assignable to type 'N'.
//   Types of property 'x' are incompatible.
//     Type 'string' is not assignable to type 'number'.(2322)
const y: N = create(x, draft => {

  // Type 'number' is not assignable to type 'string'.(2322)
  draft.x = Number(draft.x)
})

And without loosing type safety! I mean, I can write create(x as any, (draft) => { ... }), but this is not nice.

I checked your tests, and you just shuts TypeScript up with @ts-ignore in such cases. https://github.com/unadlib/mutative/blob/3c2e66bbfb7dc117a1c29a552790192b07e7a943/test/create.test.ts#L1764-L1765

I don't know how to do it though... Maybe pass draft object to mutation function twice? Actually it will be the same object, but you can say to TypeScript, that they are not. Something like this:

const y: N = create(x, (draft: S, resultDraft: N) => {
  resultDraft.x = Number(draft.x)
})

Just an idea

unadlib commented 1 year ago

hi @victordidenko , I understand your idea, but whether it's Immer or Mutative, they both generate the next immutable data through mutation of the draft. so the draft type should be definite. You might consider implementing the type like this:

type S = {
  x: string | number;
};

const x: S = {
  x: '10',
};

const y = create(x, (draft) => {
  draft.x = Number(draft.x);
});

For your example, it might be possible to use type assertions to solve this type issue.

type N = {
  x: number;
};

const y = create(x, (draft) => {
  draft.x = Number(draft.x);
}) as N;

The type of the immutable data should be { x: string | number; }. Deliberately changing this fact may cause some unnecessary type issues.

If I have misunderstood anything, feel free to discuss it.

victordidenko commented 1 year ago

Is it conceptual limitation?

I have thought I could write such helper myself, maybe this will be enough for my cases 🤔 Something like this:

function createx<X, Y>(
  original: X,
  mutation: (draft: Draft<X>, result: Draft<Y>) => void
): Y {
  return create(
    original,
    (draft) => mutation(draft, draft as any as Draft<Y>)
  ) as any as Y
}

I understand this is a huge leap of faith, and user could end up with badly typed object, for example if some field is required in Y, but user forgot to set it, TypeScript will say nothing. And later this could throw an exception like reading property from undefined...

unadlib commented 1 year ago

Yes, the key point here is draft === resultDraft is true? Obviously, they are strictly equal.

The general notion of the same set of immutable data is that they would have consistent types, otherwise they do not belong to an immutable data set.

victordidenko commented 1 year ago

I mean, if it is a conceptual limitation of the library, you can close this issue :) I'll try my way with helpers in my case.

Or if you think this could be possible, and nice to have, to opt return type somehow (not exactly using my idea, maybe somehow with generics) — this issue could be like tracker for thoughts.

I think there is some request in community for this feature, I definitely saw such questions about immer.

unadlib commented 1 year ago

Both Immer and Mutative support currying, and the currying parameters conflicts with the current parameter in your proposal.

const baseState = {};
const producer = create((draft, p) => {
  console.log(p); // console log `1`
});
const state = producer(baseState, 1);

I didn't find any relevant tickets in Immer's list of issues, if you do find them, feel free to discuss it.

Overall, this proposal has two points highlighted:

  1. The immutable data should have a consistent type for the same collection, which is a conceptual restriction on the immutable data type
  2. Both Immer and Mutative support currying, which can cause parameters to conflict.
victordidenko commented 1 year ago

Here is a (still unanswered after 9 months) question with the same issue https://stackoverflow.com/questions/73554880/produce-a-different-type-with-immer-in-typescript

unadlib commented 1 year ago

Here is a (still unanswered after 9 months) question with the same issue https://stackoverflow.com/questions/73554880/produce-a-different-type-with-immer-in-typescript

I think it should be typed as follows:

type Data<T> = Immutable<{ data: T }>
const a: Data<number|string> = { data: 0 }

draft === resultDraft, Two exactly equal parameters with different types is itself a misunderstanding.

victordidenko commented 1 year ago

It was just an example, that not only me who has this question.

I understand your answers :) I don't understand though, do you agree that there is a room for improvements, or you disagree?

unadlib commented 1 year ago

From this scenario, I suggest it can be solved with type assertions, or the helper you mentioned before.

Personally, I don't recommend this, it may cause type consistency problems or ambiguity in the immutable data type collection.

victordidenko commented 1 year ago

Ok, got you! I'll close this then