Poor man's ADT-like enum for TypeScript using template literal types.
Mimicking Algebraic Data Type (ADT) using tagged union type is a popular pattern, like it is used in redux action creators.
This package makes you write them easily. This is useful not only for redux actions, but also for anything like "enum having values".
npm install @fsubal/algebraic-enum
import algebraic, { nullary } from "@fsubal/algebraic-enum";
const ItemAction = algebraic("ItemAction", {
loaded: nullary,
selectedOne: (nextId: number) => ({ nextId }),
});
ItemAction.loaded(); // => { type: 'ItemAction/loaded', payload: {} }
ItemAction.selectedOne(1); // => { type: 'ItemAction/selectedOne', payload: { nextId: 1 } }
You can get the type of all possible values using Case<typeof ...>
import { Case, unreachable, unreachableSilent } from "@fsubal/algebraic-enum";
type KnownItemAction = Case<typeof ItemAction>; // { type: 'ItemAction/loaded', payload: {} } | { type: 'ItemAction/selectedOne', payload: { nextId: number } }
const reducer = (currentState: State, action: KnownItemAction) =>
immer(currentState, (state) => {
switch (action.type) {
case "ItemAction/loaded": {
state.loading = false;
break;
}
case "ItemAction/selectedOne": {
// This IS inferred from action.type !!!!
const { nextId } = action.payload;
state.nextId = nextId;
break;
}
default: {
// You CAN check the cases are exhaustive
unreachable(action);
// use `unreachableSilent` if you do not want to throw an error
unreachableSilent(action);
}
}
});
You can configure the delimiter using createAlgebraic
. You will see the name of type
is still perfectly inferred.
import { createAlgebraic } from "@fsubal/algebraic-enum";
const algebraic = createAlgebraic({ delimiter: "::" });
const ItemAction = algebraic("ItemAction", {
loaded: nullary,
selectedOne: (nextId: number) => ({ nextId }),
});
ItemAction.selectedOne(1); // => { type: 'ItemAction::selectedOne', payload: { nextId: 1 } }
type KnownItemAction = Case<typeof ItemAction>; // { type: 'ItemAction::loaded', payload: {} } | { type: 'ItemAction::selectedOne', payload: { nextId: number } }
This package cannot create enum with generic type (like Option<T>
or Either<L, R>
) in streight manner.
// YOU CANNOT DO THIS !!!
const Option = algebraic<T>("Option", {
Some(value: T) {
return value;
},
None: nullary,
});
// YOU CANNOT DO THIS TOO !!!
const Option = algebraic("Option", {
Some<T>(value: T) {
return value;
},
None: nullary,
});
// This will be `unknown` type...
Option.Some(1).payload;
You can workaround like this ( this is because it is "Poor man's ADT-like enum" ).
function Option<T = never>() {
return algebraic("Option", {
Some: (value: T) => value,
None: nullary,
});
}
Option<number>().Some(1);
But you cannot use Case<T>
for Option<T>
. You will find that Case<ReturnType<typeof Option>>
is like...
| {
type: "Option/Some";
payload: unknown; // Cannot be inferred
}
| {
type: "Option/None";
payload: {};
}
This is rooted in TypeScript compiler's limitation ( you cannot use generic function for ReturnType
. )
You can do this instead.
function Option<T = never>() {
return algebraic("Option", {
Some: (value: T) => value,
None: nullary,
});
}
const OptionNumber = Option<number>();
OptionNumber.Some(1);
type KnownOptionNumber = Case<typeof OptionNumber>;
Or if you would like to add richer behaviors, delegation/composition inside class is recommended. See our examples
class Option<T> {
private options = algebraic('Option', {
Some: (value: T) => value,
None: nullary,
})
private value: Case<Option<T>['options']> = this.options.None()
constructor(value: T | null | undefined) {
if (value != null) {
this.value = this.options.Some(value)
}
}
}
WIP