-
Notifications
You must be signed in to change notification settings - Fork 14
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
Conversation
Once/If you accept this PR I will make another one that will cover all api changes in README. |
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
s/payload
/value
/
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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]> } |
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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 })
Thinking about this some more, I think there are a couple of use cases being conflated here:
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 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? |
I suppose something is lost if we overload |
It's also worth considering how easy it is already to do immutable updates with an external library like 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;
}
}); |
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. |
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. |
upd: 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. |
upd2: 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. |
Yeah, I think I'd rather wait until a cleaner solution presents itself.
I'm not sure. The separate |
I like I can rework this PR to introduce it. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good 👍
Do have any ideas how to achieve it differently?