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

Added transform function (instead of update) #23

Merged
merged 3 commits into from
Apr 9, 2018
Merged

Conversation

twop
Copy link
Contributor

@twop twop commented Apr 8, 2018

  • This PR allows to conveniently work with redux states modeled with unionize.
  • update takes an object and update cases. If the cases are unmet the original object is returned
  • If there is a met case the result of it is either merged to the object or replaced. It has Partial signature for update result. So strings, numbers, Dates are being replaced and plain objects merged in.
  • this is done via the most brilliant api possible
// Should we merge objects or just replace them?
// was unable to find a better solution to that
const objType = Object.prototype.toString.call({}); // [object Object]
const isObject = (maybeObj: any) => Object.prototype.toString.call(maybeObj) === objType;
const immutableUpd = (old: any, updated: any) =>
  isObject(old) ? Object.assign({}, old, updated) : updated;

Do have any ideas how to achieve it differently?

  • For me personally this is probably one of the most needed changes. I use unionize only for redux states.
const Data = unionize({AB: ofType<{ a: number; b: string }>()});
const ab = Data.AB({ a: 1, b: 'hi' });
Data.update(ab, { AB: ({ b }) => ({ b: b + '!' }) }); // {tag:'AB', a: 1, b: 'hi!'}

@twop
Copy link
Contributor Author

twop commented Apr 8, 2018

Once/If you accept this PR I will make another one that will cover all api changes in README.

@coveralls
Copy link

coveralls commented Apr 8, 2018

Coverage Status

Coverage remained the same at 100.0% when pulling 115177e on twop:master into b8cb841 on pelotom:master.

@twop
Copy link
Contributor Author

twop commented Apr 8, 2018

Improved typings for tag property in config obj

src/index.ts Outdated
@@ -55,8 +65,9 @@ export type NoDefaultRec<Val> = {
*
* @param record A record mapping tags to value types. The actual values of the record don't
* matter; they're just used in the types of the resulting tagged union. See `ofType`.
* @param tagProp An optional custom name for the tag property of the union.
* @param valProp An optional custom name for the value property of the union. If not specified,
* @param config An optional config object. By default tag='tag' and payload is merged into object itself
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For consistency, let's say "value" instead of "payload" here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do

src/index.ts Outdated
@@ -75,6 +86,8 @@ export function unionize<Record extends MultiValueRec, TagProp extends string>(
export function unionize<Record>(record: Record, config?: { value?: string; tag?: string }) {
const { value: valProp = undefined, tag: tagProp = 'tag' } = config || {};

const payload = (variant: any) => (valProp ? variant[valProp] : variant);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/payload/value/

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted a helper function that will extract value from an object. What about 'extractVal()'?

src/index.ts Outdated
// Should we merge objects or just replace them?
// was unable to find a better solution to that
const objType = Object.prototype.toString.call({});
const isObject = (maybeObj: any) => Object.prototype.toString.call(maybeObj) === objType;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I'm not sure how reliable or efficient this sort of check is. Does it even make sense to "update" something that's a primitive type? If not, could we statically forbid using this method in that case?

Copy link
Contributor Author

@twop twop Apr 9, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it does make sense to have update for primitive types (and things like Date, Array etc).
string -> string, number->number, array->array. So the output has to match exactly the input type.

But for records (plain object) it makes sense to have partial update. The best reference that I found is react setState signature. They use Pick<K,T> type and not partial.

src/index.ts Outdated
@@ -30,6 +31,15 @@ export type Match<Record, Union> = {
<A>(variant: Union, cases: MatchCases<Record, Union, A>): A;
};

export type UpdateCases<Record> = Partial<
{ [T in keyof Record]: (value: Record[T]) => Partial<Record[T]> }
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Partial for the return type here is unsafe because it allows overwriting values with undefined. I'm not sure if inference would be able to handle using Pick though... Perhaps we should statically disallow values to be undefined, which we're already doing in the case of SingleValueVariants.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is possible to not use Partial + remove this magic check for 'isObject'.
I think the way to do it is this:

function update<T>(val:T):T | Part<T> {}; // Not Partial
type Part<T> = {_opaque:T}//  is opaque for user.

// helper function to create a part
function part<K extends keyof T, T>(p: Pick<K,T>) : Part<T> {
   // tag that this is a part object for implementation
   return  {__part: p} as any as Part<T>
}

// simplified implementation for update
function updateObject(oldV, newV) {
if (typeof newV === 'object'  && newV.__part)  {
   // merge objects
   return merged.
   }
   // if it is not a part it has to be a full object.
   return newV;
} 

So you either have to provide a complete object (not Partial) or use a helper function.

const Data = unionize({AB: ofType<{ a: number; b: string }>()});
const ab = Data.AB({ a: 1, b: 'hi' });
Data.update(ab, { AB: ({ b }) => part({ b: b + '!' }) }); //note usage of 'part'
Data.update(ab, { AB: ({ a, b }) => ({a, b: b + '!' }) }); // or provide a full shape

Not 100% that it is worth complexity. But thinking about updates again, I would either prefer a solution with 'part' function without 'isObject' magic or forbid partial updates. Note that in that case it is still useful:

  • don't have to provide default
  • don't have to call type constructor
// a bit verbose
Data.match(ab, { AB: ({ a, b }) => Data.AB({a, b: b + '!' }), default: i=>i })

@pelotom
Copy link
Owner

pelotom commented Apr 8, 2018

Thinking about this some more, I think there are a couple of use cases being conflated here:

  1. Being able to match on only a subset of the tags, without specifying a default, as long as the return type is still the union type
  2. Being able to immutably "modify" a union variant by specifying a minimal patch

Additionally with this current PR, (1) is not fully handled, because one might want to take an old variant and give it not only a new value, but a completely new tag. For example, this would be nice to support:

const State = unionize({
  ok: {},
  error: ofType<{ message: string }>(),
});

// no "ok" case, and no default!
const clearError = State.match({ error: () => State.ok({}) });

I think we can easily support this by just adding another variant to MatchCases:

export type MatchCases<Record, Union, A> =
  | Cases<Record, A> & NoDefaultProp
  | Partial<Cases<Record, Union>> & NoDefaultProp // <--
  | Partial<Cases<Record, A>> & { default: (variant: Union) => A };

If we do that, then perhaps (2) becomes a slightly simpler problem?

@pelotom
Copy link
Owner

pelotom commented Apr 8, 2018

I suppose something is lost if we overload match in that way, which is that you can't get exhaustiveness checking if the result type of match is the union type... 🤔

@pelotom
Copy link
Owner

pelotom commented Apr 8, 2018

It's also worth considering how easy it is already to do immutable updates with an external library like immer:

import produce from 'immer';

const State = unionize({
  foo: {},
  bar: ofType<{ x: string; y: boolean }>(),
});

declare const state: typeof State._Union;

const result = produce(state, copy => {
  switch (state.tag) {
    case 'bar': copy.y = true;
  }
});

@twop
Copy link
Contributor Author

twop commented Apr 9, 2018

I think your idea about transforming states makes total sense but I think it is better to have it as a separate function.

export type TransformCases<Record, Union> = Partial<Cases<Record, Union>> 

Then your example

const State = unionize({
  ok: {},
  error: ofType<{ message: string }>(),
});

// clearError:: Union -> Union.
const clearError = State.transform({ error: () => State.ok({}) });

I guess if you think about app state as a state machine (Loading->Loaded or Error) then this signature makes perfect sense. Although it is probably a bit excessive to have both 'transform' and 'update'. Not sure which one I would pick if I have to pick only one.

@twop
Copy link
Contributor Author

twop commented Apr 9, 2018

I will try to prototype the solution with 'part' helper function. I'm not 100% sure that you can express that in ts without specifying types (not sure if everything can be inferred). But overall I'm not happy with current "magic" code(isObject) of partial updates.

@twop
Copy link
Contributor Author

twop commented Apr 9, 2018

upd:
typings for 'part' functions worked out

  const Data = unionize({
      A: ofType<{ a: 'not used' }>(),
      BC: ofType<{ b: number; c: string }>(),
    });

    const bc = Data.BC({ b: 1, c: 'hi' });
    const expected = Data.BC({ b: 1, c: 'hi!' });
    // part
    expect(Data.update({ BC: ({ c }) => part({ c: c + '!' }) })(bc)).toEqual(expected);
    //whole obj
    expect(Data.update(bc, { BC: ({ b, c }) => ({ b, c: c + '!' }) })).toEqual(expected); 

Here are the types for it

export type UpdateCases<Record> = Partial<
  { [T in keyof Record]: (value: Record[T]) => Record[T] | Part<Record[T]> }
>;
export interface Part<T> { __opaque: T; }

export function part<K extends keyof T, T>(p: Pick<T, K>): Part<T> {
  return (undefined as any) as Part<T>;
}

Not sure if this new function is worth it. But I do like this solution because there is no magic involved + you cannot provide 'undefined' for values. Also it is always clear when you want a partial update vs whole object.

At work I use partial updates extensively. I will update this PR with part function regardless. And then we can discuss having 'transform' on top/instead.

@twop
Copy link
Contributor Author

twop commented Apr 9, 2018

upd2:
impl:

export function part<K extends keyof T, T extends { [k: string]: any; }>(p: Pick<T, K>): Part<T> {
  return ({ __part: p } as any) as Part<T>;
}

const immutableUpd = (old: any, updated: any) => {
  if (typeof old === 'object' && typeof updated === 'object' && updated.__part) {
    return Object.assign({}, old, updated.__part);
  }
  return updated;
};

And here are various ways to mess up

it('shoots yourself in the foot', () => {
    const InTheFoot = unionize(
      {
        d: ofType<Date>(),
        s: ofType<string>(),
        n: ofType<number>(),
        a: ofType<number[]>(),
      },
      { value: 'payload' },
    );

    InTheFoot.update({ s: i => part('str?') }); // Error: string not assignable to object
    InTheFoot.update({ d: i => part(new Date(i.getTime())) }); // Date is just fine
    InTheFoot.update({ n: i => part(i) }); // Error: number is not assignable to object
    InTheFoot.update({ a: i => part(i) }); // array is fine as well
  });

Taken that into account I would probably postpone partial updates unless we improve typings or handle arrays at runtime. Without partial update is there a value in adding update? Is there a value in adding a separate 'transform' function?

What are your thoughts?

NOTE: using 2.8 I tried to forbid usage of strings/arrays etc but was unable to do so.

@pelotom
Copy link
Owner

pelotom commented Apr 9, 2018

Taken that into account I would probably postpone partial updates unless we improve typings or handle arrays at runtime.

Yeah, I think I'd rather wait until a cleaner solution presents itself.

Without partial update is there a value in adding update? Is there a value in adding a separate 'transform' function?

I'm not sure. The separate transform function appeals to me because it's very straightforward and (seems) useful.

@twop
Copy link
Contributor Author

twop commented Apr 9, 2018

I like transform as well. IMHO it has cleaner semantics. Plus transform will have some utility for me as well.

I can rework this PR to introduce it.
There is a branch with update logic. So It won't be lost.

@twop twop changed the title Added update function Added transofrm function (instead of update) Apr 9, 2018
Copy link
Owner

@pelotom pelotom left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good 👍

@pelotom pelotom merged commit 955d346 into pelotom:master Apr 9, 2018
@pelotom pelotom changed the title Added transofrm function (instead of update) Added transform function (instead of update) Apr 23, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants