pelotom / unionize Goto Github PK
View Code? Open in Web Editor NEWBoilerplate-free functional sum types in TypeScript
License: MIT License
Boilerplate-free functional sum types in TypeScript
License: MIT License
It would be great if unionize could support union of generic variants
I'm using a generic interface in one of my projects to represent generic fetch status
export interface Fetch<T> {
results: T[]
finished: boolean
}
and I want it to become
export interface FetchLoading {
type: 'loading'
}
export interface FetchSuccess<T> {
type: 'success'
results: T[]
finished: boolean
}
export interface FetchSuccessWithErrors<T> {
type: 'successWithErrors'
results: T[]
finished: boolean
errors: string[]
}
export interface FetchError {
type: 'error'
reason: string
}
And I would create an union type which could be consumed by a UI component showing the fetch status according to its type.
How could we achieve that with unionize?
I think this can be boiled down to a TypeScript issue. In the following example, foo
is not renamed within match
, but it should be:
{
type MyActions = { foo: {}; bar: {} };
type Cases = Record<keyof MyActions, {}>;
const match = (cases: Cases) => {}
match({
foo: 1,
bar: 2,
})
}
If Record
is replaced with mapped types, renaming does work:
{
type MyActions = { foo: {}; bar: {} };
type Cases = { [key in keyof MyActions]: {} };
const match = (cases: Cases) => {}
match({
foo: 1,
bar: 2,
})
}
I opened an issue on TypeScript to track this microsoft/TypeScript#20272.
Filing here in case other people find the same problem when using unionize!
(And thanks for the fantastic library.)
import { ofType, unionize } from 'unionize';
const MyUnion = unionize({
MyVariant: ofType<{ foo: string }>(),
});
type MyUnion = typeof MyUnion._Union;
const myUnion = MyUnion.MyVariant({ foo: 'bar' });
MyUnion.match(myUnion, {
// Type: { foo: 'bar' }
// Expected logged value: { foo: 'bar' } (same as type)
// Actual logged value: { tag: 'MyVariant', foo: 'bar' }
MyVariant: myVariant => console.log(myVariant),
});
Workaround: if we specify a value
, we don't have this issue:
const MyUnion = unionize(
{
MyVariant: ofType<{ foo: string }>(),
},
{ value: 'value' },
);
Why this matters: we are spreading the unionize variant value into a React component:
<div {...myVariant} />
We expect the spreaded object to contain valid props for this React component, which it does, however it also passes the tag prop which is not valid. This results in invalid HTML.
IMO the runtime value should match the type. There should not be an excess property containing the tag—although I realise this would mean creating a new object, in order to remove the tag
Looks like going through proxies is very slow (more than two order of magnitude here).
https://jsperf.com/proxy-cost/8
..and maybe not necessary.
In Elm you can match against multiple types at once, by storing them in a tuple and then matching against that:
type Action = AgreeToTerms | AddFiles { files : List File }
type State = Onboarding | Form { files : List File }
myMatch : Action -> State -> State
myMatch action state = case ( action, state ) of
( AgreeToTerms, Onboarding ) ->
Form { files = [] }
( AddFiles { files }, Form form ) ->
Form { files = form.files }
( _ , _ ) ->
state
Full example: https://github.com/rtfeldman/elm-spa-example/blob/17a3398623f9e538f14f5b0d039fd62b3beae934/src/Main.elm#L210
Currently, if we want to do the same via Unionize, the best we can do is nesting match
s:
const Action = unionize({
AgreeToTerms: ofType<{}>(),
AddFiles: ofType<{ files: File[] }>(),
});
type Action = UnionOf<typeof Action>;
const State = unionize({
Onboarding: ofType<{}>(),
Form: ofType<{ files: File[] }>(),
});
type State = UnionOf<typeof State>;
const myMatch = (action: Action) => (state: State) =>
Action.match({
AgreeToTerms: () =>
State.match({
Onboarding: () => State.Form({ files: [] }),
default: () => state,
})(state),
AddFiles: ({ files }) =>
State.match({
Form: form => State.Form({ files: form.files.concat(files) }),
default: () => state,
})(state),
default: () => state,
})(action);
It would be amazing if we could somehow provide a syntax for matching mutliple unions simultaneously. Pseudo code:
const myMatch = match([Action, State])([
[[['AgreeToTerms', 'Onboarding']], () =>
State.Form({ files: [] })
],
[['AddFiles', 'Form'], ({ files }, form) =>
State.Form({ files: form.files.concat(files) })
],
[['default', 'default'], (_, state) =>
state
]
])
I have no idea if that would be possible with TS, though. WDYT?
Hi 👋
first of all, thanks for this awesome utility library, I am having great time using it.
However, there is one use case which I think could be optimized. I find myself in situations, where I need to get access to the underlying string constants behind my actions/unions.
This leads me to write unions like this, which gets very boilerplateful and verbose pretty fast.
const ON = 'ON';
const OFF = 'OFF';
const State = unionized({
[ON]: ofType<{}>(),
[OFF]: ofType<{}>()
})
// do something later with tag
console.log(ON) // 'ON'
In perfect world, .is
, .match
and .transform
should be enough, but when interfacing with specific libraries the need to access union tags is unavoidable. This in turn requires the user to sacrifice brevity.
Proposal
Store Record<tags, tags> inside the Unionized
object.
Usage example:
const State = unionized({
ON: ofType<{}>(),
OFF: ofType<{}>()
})
// do something later with tag
console.log(State.tags.ON) // 'ON'
I will be 100% willing and happy to prepare a PR with that feature, just let me know if you are interested in it and would like to include such functionality into the codebase.
Cheers 😄
is
and match
do not provide anything.
typescript version is 2.4.2
I guess it may be related to a problem with the Proxy definition.
I just ran into this bug which resulted in a runtime exception. 😢
import { unionize, ofType, UnionOf } from 'unionize';
const NestedAction = unionize({ Foo: {} }, { value: 'value' });
type NestedAction = UnionOf<typeof NestedAction>;
const Action = unionize({ Nested: ofType<NestedAction>() }, { value: 'value' });
declare const wrong: { value: {} };
// No error!!!
Action.Nested(wrong);
// Expected: (value: { tag: "Foo"; } & { value: {}; }) => …
// Actual: (value: Pick<{ tag: "Foo"; } & { value: {}; }, "value">) => …
// Why is `tag` omitted??
Action.Nested;
One known workaround is to force the nested union tag to be different:
-const NestedAction = unionize({ Foo: {} }, { value: 'value' });
+const NestedAction = unionize({ Foo: {} }, { tag: 'tag2', value: 'value' });
However that's a non-option when modelling Redux actions, since the tag must be type
to conform to the usual Redux action type.
ERROR in node_modules/unionize/lib/index.d.ts(17,29): error TS1005: ';' expected.
node_modules/unionize/lib/index.d.ts(17,66): error TS1005: '(' expected.
node_modules/unionize/lib/index.d.ts(17,104): error TS1005: ']' expected.
node_modules/unionize/lib/index.d.ts(17,116): error TS1005: ',' expected.
node_modules/unionize/lib/index.d.ts(17,119): error TS1005: ';' expected.
node_modules/unionize/lib/index.d.ts(17,182): error TS1005: ']' expected.
node_modules/unionize/lib/index.d.ts(17,194): error TS1005: ';' expected.
node_modules/unionize/lib/index.d.ts(17,195): error TS1128: Declaration or statement expected.
node_modules/unionize/lib/index.d.ts(18,1): error TS1128: Declaration or statement expected.
node_modules/unionize/lib/index.d.ts(43,36): error TS1005: ';' expected.
node_modules/unionize/lib/index.d.ts(44,23): error TS1005: ';' expected.
node_modules/unionize/lib/index.d.ts(45,7): error TS1128: Declaration or statement expected.
node_modules/unionize/lib/index.d.ts(45,19): error TS1005: ';' expected.
node_modules/unionize/lib/index.d.ts(46,23): error TS1005: ';' expected.
node_modules/unionize/lib/index.d.ts(47,7): error TS1109: Expression expected.
node_modules/unionize/lib/index.d.ts(48,1): error TS1128: Declaration or statement expected.
node_modules/unionize/lib/index.d.ts(49,79): error TS1005: ';' expected.
node_modules/unionize/lib/index.d.ts(49,90): error TS1128: Declaration or statement expected.
node_modules/unionize/lib/index.d.ts(50,17): error TS1005: ']' expected.
node_modules/unionize/lib/index.d.ts(50,23): error TS1005: ',' expected.
node_modules/unionize/lib/index.d.ts(50,24): error TS1136: Property assignment expected.
node_modules/unionize/lib/index.d.ts(50,28): error TS1005: ':' expected.
node_modules/unionize/lib/index.d.ts(50,36): error TS1005: ':' expected.
node_modules/unionize/lib/index.d.ts(50,55): error TS1005: ',' expected.
node_modules/unionize/lib/index.d.ts(51,9): error TS1005: ']' expected.
node_modules/unionize/lib/index.d.ts(51,15): error TS1005: ';' expected.
node_modules/unionize/lib/index.d.ts(51,16): error TS1109: Expression expected.
node_modules/unionize/lib/index.d.ts(51,18): error TS1109: Expression expected.
ℹ 「wdm」: Failed to compile.
When a union is nested inside a union, without a value
property specified, the two values will collide:
E.g.
https://stackblitz.com/edit/typescript-ef4lbo?file=index.ts
import { unionize, ofType } from 'unionize';
const InsideFoo = unionize({
Bar: ofType<{ bar: number }>(),
});
type InsideFoo = typeof InsideFoo._Union;
const A = unionize({
Foo: ofType<InsideFoo>(),
});
const a = A.Foo(InsideFoo.Bar({ bar: 1 }));
console.log(JSON.stringify(a))
Logs:
{"bar":1,"tag":"Foo"}
As you can see, the tag for the nested union is lost, and the value for the nested union is merged with the value for the parent union.
We can fix this by specifying a value
property:
import { unionize, ofType } from 'unionize';
const InsideFoo = unionize({
Bar: ofType<{ bar: number }>(),
}, { value: 'value' });
type InsideFoo = typeof InsideFoo._Union;
const A = unionize({
Foo: ofType<InsideFoo>(),
}, { value: 'value' });
const a = A.Foo(InsideFoo.Bar({ bar: 1 }));
console.log(JSON.stringify(a))
Logs:
{"tag":"Foo","value":{"tag":"Bar","value":{"bar":1}}}
For this reason, I think that the value
property should be required. I always forget to add one, and then run into these issues. Ideally I wouldn't have to specify a value
property each time I use unionize
.
Related code:
Line 115 in f6ebfa5
Do you have any suggestions how to validate values as unionize types? Here is a real world example of where this is needed.
I have a union type to represent a page modal.
const Modal = unionize({
Foo: ofType<{ foo: number }>(),
Bar: ofType<{ bar: number }>()
});
type Modal = typeof Modal._Union;
The modal is specified to the application through the URL as a query param, e.g. http://foo.com/?modal=VALUE
, where VALUE
is an object after it has been JSON stringified and URI encoded, e.g.
`?modal=${encodeURIComponent(JSON.stringify(Modal.Foo({ foo: 1 })))}`
// ?modal=%7B%22tag%22%3A%22Foo%22%2C%22foo%22%3A1%7D
In my application I want to be able to match against the modal using the match helpers provide by unionize. However, it is not safe to do so, because the modal
query param could be a string containing anything, e.g. modal=foo
.
For this reason I need to validate the value before matching against it.
In the past I've enjoyed using io-ts for the purpose of validating. I am aware you also have a similar library called runtypes.
If this is a common use case, I wonder if there's anything we could work into the library, or build on top of it, to make it easier.
Here is a working example that uses io-ts and tries to share as much as possible, but duplication is inevitable and it's a lot of boilerplate.
import { ofType, unionize } from "unionize";
import * as t from "io-ts";
import { option } from "fp-ts";
//
// Define our runtime types, for validation
//
const FooRT = t.type({ tag: t.literal("Foo"), foo: t.number });
type Foo = t.TypeOf<typeof FooRT>;
const BarRT = t.type({ tag: t.literal("Bar"), bar: t.number });
type Bar = t.TypeOf<typeof BarRT>;
const ModalRT = t.taggedUnion("tag", [FooRT, BarRT]);
//
// Define our unionize types, for object construction and matching
//
export const Modal = unionize({
Foo: ofType<Foo>(),
Bar: ofType<Bar>()
});
export type Modal = typeof Modal._Union;
//
// Example of using the unionize object constructors
//
const modalFoo = Modal.Foo({ foo: 1 });
//
// Example of validation with io-ts + matching with unionize
//
const parseJsonSafe = (str: string) => option.tryCatch(() => JSON.parse(str));
const validateModal = (str: string) => {
console.log("validating string:", str);
parseJsonSafe(str).foldL(
() => {
console.log("invalid json");
},
parsedJson => {
ModalRT.decode(parsedJson).fold(
() => console.log("parsed json, invalid modal"),
modal =>
Modal.match({
Foo: foo =>
console.log("parsed json, valid modal foo", foo),
Bar: bar =>
console.log("parsed json, valid modal bar", bar)
})(modal)
);
}
);
};
validateModal(JSON.stringify({ tag: "Foo", foo: 1 }));
/*
validating string: {"tag":"Foo","foo":1}
parsed json, valid modal foo { tag: 'Foo', foo: 1 }
*/
validateModal(JSON.stringify({ tag: "Bar", bar: 1 }));
/*
validating string: {"tag":"Bar","bar":1}
parsed json, valid modal bar { tag: 'Bar', bar: 1 }
*/
validateModal("INVALID JSON TEST");
/*
validating string: INVALID JSON TEST
invalid json
*/
I started to use unionize at work for modelling redux states. So far it is a great conceptual fit however we miss several features/syntax :)
Usually it looks similar to this.
const State = unionize({
Loading: ofType<{}>(), // note there is no payload
Loaded: ofType<{ data: string }>(),
Error: ofType<{ err: string }>()
});
So far we have way more inline usage of matching (when there is an object to match with right away).
default syntax is a bit verbose ()=>{...}
payload for default case is not the initial object (useful for updates)
Suggestion
const val = State.Loaded({ data: 'some data' });
const str = State.match(val, { // note that val is a fist arg here
Loaded: ({ data }) => data,
_: v => 'default' // default, v === val here
});
"_" is a reserved prop for default case. Which is aligned with other languages + probably won't be used (vs "def" or "default")
"_" is a fallback case + the original object is passed as payload so it is possible to write {_:identity} where identity = x=>x
inline usage vs curried. Maybe another func name: matchInl, matchI or even switch :)
const State = unionize({
Loading: simple(), // need a better name though, "noArgs" maybe?
Loaded: ofType<{ data: string }>(),
Error: ofType<{ err: string }>()
});
const loading = State.Loading(); // note no args to create it.
//So it can even be memoized (return the same object all the time)
this comes up a lot with dealing with redux. I need to modify data only if state is loaded.
const updatedVal = State.update(val, {
Loaded: ({ data }) => ({ data: data + ' yay!' })
});
Note:
I know that in ur example it was used to represent redux actions but writing
const Action = unionize({
ADD_TODO: ofType<{ id: string; text: string }>(),
}, 'type', 'payload');
const action = Action.ADD_TODO({...})
is a bit tedious.
suggestion
const Todos = unionize(
{ Add: ofType<{ id: string; text: string }>() },
{ tag: 'type', payload: 'payload', prefix: '[TODOS] ' }
);
type Config = { tag?: string; payload?: string; prefix?: string };
//so you can simplify creating new actions
const namespace = (prefix: string) => ({ tag: 'type', payload: 'payload', prefix })
const Todos = unionize(
{ Add: ofType<{ id: string; text: string }>() },
namespace( '[TODOS] ')
);
type AddType = typeof Todos._Record.Add
// {tag: "[TODOS] Add", payload: {id: string; text: string}}
I know this is a lot of suggestions but I decided to reach out to see if they make sense to you :)
And thanks for an amazing library!
When specifying an element with ofType<void>
, it was my expectation that I could then use the element factory without specifying an argument, e.g. AuthActions.LOGOUT()
. Instead it turns out I have to do AuthActions.LOGOUT(null)
or AuthActions.LOGOUT(undefined)
, which feels rather unnecessary. It seems to me like there should be a way to enable this behavior, by having two different types for the factory depending on whether the type is void or not.
Currently one has to duplicate cases:
export const getGridCountForDevice = Device.match({
Phone: () => GridCount.Two({}),
Tablet: () => GridCount.Two({}),
Desktop: () => GridCount.Three({}),
});
Or use defaults:
export const getGridCountForDevice = Device.match({
Desktop: () => GridCount.Three({}),
}, () => GridCount.Two({}));
It would be fantastic if unionize had a way of pattern matching multiple tags with a single case:
export const getGridCountForDevice = Device.match([
[['Tablet', 'Phone'], () => GridCount.Two({})],
['Desktop', () => GridCount.Three({})],
]);
Although I imagine this would complicate the behind-the-scenes types of match
quite drastically.
Hi, I was recently playing around with unionize and discovered that using {}
to represent members of the union which take no arguments doesn't actually prevent you from passing arguments through in the functions passed to match
via the type constructors. I think, ideally, you would receive a type error when trying to pass any argument at all due to that being the intention of modeling a simple, "empty" type.
Do you have any ideas for how this could be made to work? Is it possible?
it generates a variance issue (typescript 2.8.3).
Code
describe('unionOf', () => {
it('should be usable', () => {
const T = unionize(
{
foo: ofType<{ x: number }>(),
}
);
type ActionType = UnionOf<typeof T> // Error message here
});
});
Error message:
[ts]
Type 'Unionized<{ foo: { x: number; }; }, MultiValueVariants<{ foo: { x: number; }; }, "tag">>' does not satisfy the constraint 'Unionized<any, any>'.
Type 'Unionized<{ foo: { x: number; }; }, MultiValueVariants<{ foo: { x: number; }; }, "tag">>' is not assignable to type '{ _Tags: string; _Record: any; _Union: any; is: Predicates<any>; as: Casts<any, any>; match: Matc...'.
Types of property 'match' are incompatible.
Type 'Match<{ foo: { x: number; }; }, { tag: "foo"; } & { x: number; }>' is not assignable to type 'Match<any, any>'.
Types of parameters 'cases' and 'cases' are incompatible.
Type 'MatchCases<any, any, any>' is not assignable to type 'MatchCases<{ foo: { x: number; }; }, { tag: "foo"; } & { x: number; }, any>'.
Type 'Cases<any, any> & NoDefaultProp' is not assignable to type 'MatchCases<{ foo: { x: number; }; }, { tag: "foo"; } & { x: number; }, any>'.
Type 'Cases<any, any> & NoDefaultProp' is not assignable to type 'Partial<Cases<{ foo: { x: number; }; }, any>> & { default: (variant: { tag: "foo"; } & { x: numbe...'.
Type 'Cases<any, any> & NoDefaultProp' is not assignable to type '{ default: (variant: { tag: "foo"; } & { x: number; }) => any; }'.
Types of property 'default' are incompatible.
Type 'undefined' is not assignable to type '(variant: { tag: "foo"; } & { x: number; }) => any'.
The pattern used by UnionOf
works well with interfaces but not with types (variance handling is not the same I guess).
Union
cannot be made an interface because it mixes Creators
which cannot be made an interface.
One possible solution would be to nest Creators
inside a field of Union
, like so:
export interface Unionized<Record, TaggedRecord> {
_Tags: keyof TaggedRecord;
_Record: Record;
_Union: TaggedRecord[keyof TaggedRecord];
is: Predicates<TaggedRecord>;
as: Casts<Record, TaggedRecord[keyof TaggedRecord]>;
match: Match<Record, TaggedRecord[keyof TaggedRecord]>;
transform: Transform<Record, TaggedRecord[keyof TaggedRecord]>;
create: Creators<Record, TaggedRecord>
}
(obviously you need to change the way creators are mixed into the related function)
In that case it works, however that's a breaking change.
When using Partial
as a value type the calling the constructors with any object type checks but it should not. For example the following should not type check
type T = { field: string }
const U = unionize({
PartialT: ofType<Partial<T>>(),
})
// This should fail
const u = U.PartialT({ foo: true })
const u2 = U.PartialT(true)
The underlying problems seems to be that the constraint {} extends UnTagged<Partial<T>, 'tag'>
does not hold. If we look at the definition of Creators
we see that this results in the constructor accepting an argument of type undefined | {}
which seems wrong.
export type Creators<Record, TaggedRecord, TagProp extends string> = {
[T in keyof Record]: {} extends UnTagged<Record[T], TagProp>
? ((value?: {}) => TaggedRecord[keyof TaggedRecord])
: ((value: UnTagged<Record[T], TagProp>) => TaggedRecord[keyof TaggedRecord])
}
I’m not sure what the solution would be but would be happy to create a PR if somebody has a suggestion.
I tried to use but, I do not know how to do to reducer funcion. In the example of the readme, it defines a const variable, but in @ngrx, needed a funcion
In the traditional way, in the effects, it's used actions$.ofType(), In your proposal
action$.filter(Action.is.TOGGLE_TODO). my question is, your proposal does the same? It is faster than the traditional way?
I just ran into this issue after upgrading to 2.x, which has caused a serious bug in our application.
Using an action creator without specifying a payload, for an action that expects a payload (created with typeOf), results in the payload being "autogenerated" as the empty object {}. This is a serious issue because sometimes we want to pass on the fact that there is no value being passed.
We managed to work around the issue by converting to null when calling the action creator, e.g. MyActionType.DoSomething(value || null). However this seems to be an unnecessary burden for developers to always have to do this.
Eager to use it at work :)
Enabling creating several unions from some variants
const success = ofType<AddActionSuccess>()
const failure = ofType<AddActionFailure>()
const error = ofType<AddActionError>()
export const addAction = unionize({
success,
failure
})
export const addAction2 = unionize({
success,
failure,
error
})
and also using the predicates and constructor from each variant, individually (while preserving usage from the union too!)
const success = ofType<AddActionSuccess>()
success.is(...) // predicate
success.of(...) // constructor
We cannot express the code below currently (because of Type Params):
type Success<T> = { type: 'success', value: T }
const makeSuccess = <T>(value: T): Result<T> => ({ type: 'success', value });
const isSuccess = <T>(r: Result<T>): r is Success<T> => r.type === 'success';
type Failure = { type: 'failure' }
const makeFailure = <T>(): Result<T> => ({ type: 'failure' });
const isFailure = <T>(r: Result<T>): r is Failure => r.type === 'failure';
type Result<T> = Success<T> | Failure;
It may not be possible with the current API (Not spent any time thinking about it).
However for the record, let put that here.
When a value contains a property of the same name as TagProp
, when using the constructor for that value, I expect an error if I forget to pass that property, but I don't:
import { ofType, unionize } from 'unionize';
const ContentMode = unionize({
Content: ofType<{ tag: string }>(),
});
// no error, what's going on?!!
ContentMode.Content();
import { ofType, unionize } from 'unionize';
const ContentMode = unionize({
Content: ofType<{ myTag: string }>(),
}, { tag: 'myTag' });
// no error, what's going on?!!
ContentMode.Content();
In some ways this makes sense due to the fact Unionize does not nest the value by default, so these two properties will actually collide (#46).
However, even if I enable value nesting, I still get no error:
import { ofType, unionize } from 'unionize';
const ContentMode = unionize({
Content: ofType<{ tag: string }>(),
}, { value: 'value' });
// no error, what's going on?!!
ContentMode.Content();
import { ofType, unionize, UnionOf } from 'unionize';
const MyUnion = unionize({
MyVariant: ofType<string[]>(),
});
type MyUnion = UnionOf<typeof MyUnion>;
MyUnion.match({
MyVariant: strs => strs.map(x => x), // runtime error!
});
Related: #67
Workaround: specify a value
:
const MyUnion = unionize({
MyVariant: ofType<string[]>(),
-});
+}, { value: 'value' });
For example Maybe a = Just a | Nothing
.
Is this possible? :-)
Thanks,
Olly
We need README.md updated to reflect the changes implemented in the latest release. Please this is very important to keep everything well documented.
<3 unionize!
We're using Unionize pretty heavily at Unsplash (thank you!). I would like to share some UX feedback relating to how the union types appear when inspected. To do so, I will use an example.
First let's see how type inspection works for a sans-Unionize union:
type Foo = { foo: number };
type Bar = { bar: number };
type Baz = { baz: number };
type MyNestedUnion =
| {
tag: "NestedFoo";
value: Foo;
}
| {
tag: "NestedBar";
value: Bar;
}
| {
tag: "NestedBaz";
value: Baz;
};
// Hover this
type MyUnion =
| {
tag: "Foo";
value: Foo;
}
| {
tag: "Bar";
value: Bar;
}
| {
tag: "Baz";
value: Baz;
}
| {
tag: "Union";
value: MyNestedUnion;
};
// Hover this
declare const fn: (f: MyUnion) => void;
Hover over type MyUnion
and you'll see:
type MyUnion =
| {
tag: "Foo";
value: Foo;
}
| {
tag: "Bar";
value: Bar;
}
| {
tag: "Baz";
value: Baz;
}
| {
tag: "Union";
value: MyNestedUnion;
};
Great! Hover over const fn
and you'll see:
const fn: (f: MyUnion) => void;
Great! No problems here.
Now if we define the equivalent tagged union but using Unionize:
import { ofType, unionize, UnionOf } from "unionize";
type Foo = { foo: number };
type Bar = { bar: number };
type Baz = { baz: number };
const MyNestedUnion = unionize(
{
NestedFoo: ofType<Foo>(),
NestedBar: ofType<Bar>(),
NestedBaz: ofType<Baz>(),
},
{ value: "value" },
);
type MyNestedUnion = UnionOf<typeof MyNestedUnion>;
const MyUnion = unionize(
{
Foo: ofType<Foo>(),
Bar: ofType<Bar>(),
Baz: ofType<Baz>(),
Union: ofType<MyNestedUnion>(),
},
{ value: "value" },
);
// Hover this
type MyUnion = UnionOf<typeof MyUnion>;
// Hover this
declare const fn: (f: MyUnion) => void;
Now hover over type MyUnion
and you'll see:
type MyUnion = ({
tag: "Foo";
} & {
value: Foo;
}) | ({
tag: "Bar";
} & {
value: Bar;
}) | ({
tag: "Baz";
} & {
value: Baz;
}) | ({
tag: "Union";
} & {
value: ({
tag: "NestedFoo";
} & {
value: Foo;
}) | ({
tag: "NestedBar";
} & {
...;
}) | ({
...;
} & {
...;
});
})
Observations:
{
tag: "Foo";
} & {
value: Foo;
}
{
tag: "Foo";
value: Foo;
}
Compact
(which "cleans" types with lots of arithmetic)?MyNestedUnion
type alias was shown nested inside of here. However in this Unionize example, the type alias MyNestedUnion
is thrown away and the whole type structure is shown instead. Why? Is this a design limitation or bug in TS? Is it something we can workaround?Hover over const fn
and you'll see:
const fn: (f: ({
tag: "Foo";
} & {
value: Foo;
}) | ({
tag: "Bar";
} & {
value: Bar;
}) | ({
tag: "Baz";
} & {
value: Baz;
}) | ({
tag: "Union";
} & {
value: ({
tag: "NestedFoo";
} & {
value: Foo;
}) | ({
tag: "NestedBar";
} & {
...;
}) | ({
...;
} & {
...;
});
})) => void
In the sans-Unionize example, the MyUnion
type alias was shown here. However in this Unionize example, the type alias MyUnion
is thrown away and the whole type structure is shown instead. Why?
I appreciate there might not be much we can do from Unionize's side to help address these UX issues. However I imagine there's at least some discussions inside of TS we could chime into, so our voice is heard. 🤞
How to define Action Without payload?
I have actions with payload and without payload In my definition.
With payload is: ADD_TODO: ofType<{ id: string; text: string }>()
but without How is it?
I tried this: CLEAN: ofType<{}>() but the compiler show error when call store.dispatch(Action.CLEAN())
It works, but the compiler shows me error.
What is the right way?
While testing the viability of using unionize for actions & reducers in an existing Redux app, I discovered that, despite being able to alias tag
as type
and value
as payload
, to provide a bit of Redux-compatibility, there is no real way to handle being able to add error
and meta
keys on the same level as payload
.
You could, of course, lump those keys under your value
/payload
property and then use some extra mapping step to extract those things out & create an FSA style action object, but that takes away from a lot of the convenience & nicety of using a solution like unionize.
Is there anyway around this?
Somewhat unrelated, but I'm also curious about the viability of approaching a library like this that doesn't rely on records but instead allows the type constructors for each member of the union to accept multiple arguments. I'm thinking along the lines of something like the way daggy
works, but typed with TS. Any thoughts on this? Any caveats that would make it very difficult/not possible?
I want to create a tuple of all my union's members.
To achieve this with unions elsewhere, I can do the following:
type ExactMatch<Union, Arr> = Union[] extends Arr ? (Arr extends Union[] ? Arr : never) : never;
const enumerateUnion = <T extends unknown>() => <U extends unknown>(
arg: ExactMatch<T, U>,
) => arg;
As recommended here: microsoft/TypeScript#13298 (comment)
For example:
enum Foo {
bar,
baz
}
// good
enumerateUnion<Foo>()([Foo.bar, Foo.baz])
// bad
enumerateUnion<Foo>()([Foo.bar])
However, when I tried to do this with a unionize union, I realised it's not possible because the constructors return the union rather than the (tagged) member that has been constructed:
const Foo = unionize({
bar: {},
baz: {}
});
// good (should be bad!)
enumerateUnion<Foo>()([Foo.bar()])
Have you got any alternative ideas about how we could enumerate a unionize union? Alternatively, is there a way to make unionize union's work with the enumerateUnion
I defined above?
const def = unionize({
or: ofType<{ a: Def, b: Def }>()
})
type Def = typeof def._Union // Error: Type alias 'Def' circularly references itself
One cannot create a recursive Union.
When instantiating one variant of a union, it is typed as the type of the union.
It would be nicer to have the actual narrower type of the variant.
To achieve this, one could change the signature of Creators from
export type Creators<Record, TaggedRecord> = {
[T in keyof Record]: (value: Record[T]) => TaggedRecord[keyof TaggedRecord]
}
to
export type Creators<Record, TaggedRecord> = {
[T in (keyof Record & keyof TaggedRecord)]: (value: Record[T]) => TaggedRecord[T]
}
any chance to get that in?
Is it possible to create a subset union type from an existing union type?
E.g. given type Foo = A | B | C | D
, I want to create another union that is just composed of A | B
.
Perhaps this is a bad idea and I'm looking at this the wrong way. For context, here is why I think I need this.
I have this union:
const PropertyComparison = unionize({
Same: ofType<{}>(),
SimilarFunctions: ofType<{}>(),
ValuesDeepEqual: ofType<{}>(),
Unequal: ofType<PropertyComparisonUnequal>(),
});
type PropertyComparison = typeof PropertyComparison._Union;
And this record type:
type PropertiesComparison = { [key: string]: PropertyComparison };
Now I also want to create a type like PropertiesComparison
except where the values can only be ValuesDeepEqual | SimilarFunctions
:
type PropertyComparisonSubset = PropertyComparison.ValuesDeepEqual | PropertyComparison.SimilarFunctions;
type PropertiesComparisonSubset = { [key: string]: PropertyComparisonSubset };
For libraries such as redux-observable
or @ngrx/effects
it's a regular requirement to filter a stream of actions, which can be done with unionize
like so:
this.actions$.pipe(
filter(DomainActions.is.MY_DOMAIN_ACTION),
...
);
and most of the time this is enough. However, sometimes it's necessary to filter according to multiple action types. At the moment this could be done with something like the following:
/** Example 1 **/
this.actions$.pipe(
filter(action =>
DomainActions.is.MY_DOMAIN_ACTION(action as DomainActions._Union) ||
AnotherDomainActions.is.ANOTHER_DOMAIN_ACTION(action as AnotherDomainActions._Union))
...
);
or perhaps:
/** Example 2 **/
this.actions$.pipe(
filter(testMultiple(
DomainActions.is.MY_DOMAIN_ACTION
AnotherDomainActions.is.ANOTHER_DOMAIN_ACTION
)),
...
);
function testMultiple(...predicates: ((a: any) => boolean)[]) {
return (a: any) => predicates.some(predicate => predicate(a));
}
Using something like the ngrx
ofType
operator this could be written as:
/** Example 3 **/
this.actions$.pipe(
ofType(
DomainActions.MY_DOMAIN_ACTION.name,
AnotherDomainActions.ANOTHER_DOMAIN_ACTION.name
),
...
);
but unfortunately the name
property is undefined so I can't do that. 😦
So my question is, @pelotom or anyone else who might have an idea, what is the recommendation for such a case? Should I just create my own testMultiple()
utility function à la example 2? Or is there some way to obtain the tag property as a string so I can do something like example 3? Or might there be some other way?
It appears that the return type of match clauses isn't strongly checked/inferred by the return type of the function.
In this example, you can see that the INCREMENT_COUNT
match clause function is not returning a type of StoreState
, but no compiler error is thrown. However, the DECREMENT_COUNT
clause function explicitly declares that it is returning a StoreState
type, and a compiler error is thrown.
Not sure if it is possible to make this return type inferred somehow, but it'd be nice to not have to remember to explicitly declare the return type of each match clause.
P.S. I absolutely love this library, and appreciate all the work that has been done for it. 😄 ❤️
In order to avoid name collisions for Redux action types it is currently necessary (or at least best practice) to repeat the domain in the type name, for instance:
export const AuthActions = unionize({
AUTH_LOGIN: ofType<LoginData>(),
AUTH_LOGIN_SUCCESS: ofType<AuthData>(),
AUTH_LOGOUT: ofType<void>(),
AUTH_ERROR: ofType<any>(),
}, {
tag: 'type',
value: 'payload',
});
One way to alleviate the issue would be if the type string could be prefixed, for instance:
export const AuthActions = unionize({
LOGIN: ofType<LoginData>(),
LOGIN_SUCCESS: ofType<AuthData>(),
LOGOUT: ofType<void>(),
ERROR: ofType<any>(),
}, {
tag: 'type',
value: 'payload',
tagPrefix: 'AUTH_'
});
Consumers could then reference things by their short names, e.g. AuthActions.LOGIN
while the actual string behind the scenes would be AUTH_LOGIN
, thus avoiding collisions with other non-auth domains.
However, this still wouldn't guarantee that tag name collisions wouldn't occur. For that a registry of tags would be necessary as well as a way to configure whether the tag names should be considered unique. The registry could have a structure like
const TAG_REGISTRY: {[tag: string]: string[]} = {
'type': [
'AUTH_LOGIN',
'AUTH_LOGIN_SUCCESS',
'AUTH_LOGOUT',
'AUTH_ERROR',
]
};
and the configuration whether a tag should be considered unique could be done by an option unique: true
or similar:
{
tag: 'type',
value: 'payload',
tagPrefix: 'AUTH_',
unique: true
};
Then if a tag name for the same tag (e.g. type
) were to be used twice, an error could be thrown, thus preventing accidental action type collisions.
Unionize is very useful and lean.
one can write easily such things:
const T = unionize({
foo: ofType<{x : number}>(),
});
const input = { x: 42 };
expect(T.foo(input).tag).toBe('foo');
the inferred type for foo
is then:
{
tag: "foo";
} & {
x: number;
}
However, as a union grows the signature start to be verbose get in the way, so one can use interface to keep things clearer.
interface Foo {
x: number;
}
const T = unionize({
foo: ofType<Foo>(),
});
const input = { x: 42 };
expect(T.foo(input).tag).toBe('foo');
which generates that:
{
tag: "foo";
} & Foo
It's halfway to totally removing the boilerplate if one want to go further by doing so:
interface Foo {
tag: 'foo';
x: number;
}
const T = unionize({
foo: ofType<Foo>(),
});
const input = { x: 42, tag: 'foo' as 'foo' };
expect(T.foo(input).tag).toBe('foo');
which generates that:
Foo
with (a bit) more effort we can come to this:
interface Foo {
tag: 'foo';
x: number;
}
const T = unionize({
foo: ofType<Foo>(),
});
const input = { x: 42 };
expect(T.foo(input).tag).toBe('foo');
which still generates that:
Foo
I may provide a PR if interested
when can be ready in NPM?
While we're making breaking changes for 2.0, I've been thinking about ways we could return to the simpler original API of Unionize. It used to be that the unionize
function used a type parameter instead of an actual object to specify the mapping of tags to values:
const Action = unionize<{
ADD_TODO: { id: string; text: string }
SET_VISIBILITY_FILTER: 'SHOW_ALL' | 'SHOW_ACTIVE' | 'SHOW_COMPLETED'
TOGGLE_TODO: { id: string }
}>('type', 'payload')
Simple and clean!
The magic that powered this was ES6 Proxy
s; unfortunately it was pointed out that proxies carried an enormous performance penalty, and thus we ended up with the current API involving ofType
with its impossible signature of <A>() => A
. It works and is efficient, but it's unsightly and unsafe.
How could we get back to something like what we had originally, but without using Proxy
? Well, we only needed it to trap property accesses in support of the creation, is
and as
functions:
Action.ADD_TODO({ id: 'abc', text: 'hello world' });
Action.is.ADD_TODO(obj);
Action.as.ADD_TODO(obj);
But we could alternatively make these functions which take a tag argument:
Action.create('ADD_TODO', { id: 'abc', text: 'hello world' });
Action.is('ADD_TODO', obj);
Action.as('ADD_TODO', obj);
There could be curried versions of these as well, so that
Action.create('ADD_TODO'); // returns a creation function
Action.is('ADD_TODO'); // returns a type guard
Action.as('ADD_TODO'); // returns a casting function
Slightly more verbose, but not much. The more I think about it, the more this tradeoff seems worth it to leave ofType
behind.
It's came up a few times that we don't currently have a way to test that the types behave as expected.
If someone was to have a go at doing this (e.g. me), which tool should be used for the job?
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.