Install unionize via yarn
masterTo use unionize in your project, install it using yarn:
yarn add unionizerepository·master·Indexed 19 days ago
https://github.com/pelotom/unionizeA TypeScript utility for defining and working with boilerplate-free functional sum types (discriminated unions). It provides type-safe element factories, pattern matching via match(), type guards, type casting with as(), and transformation utilities. It includes helper types like UnionOf to extract inferred union types and the ofType<T>() function to define variant data types.
To use unionize in your project, install it using yarn:
yarn add unionizeThe object returned by unionize contains factory functions for each tag. Calling these functions creates the corresponding union member object.
// If Actions is defined via unionize
const action = Actions.ADD_TODO({ id: 'c819bbc1', text: 'Take out the trash' });
const filter = Actions.SET_VISIBILITY_FILTER('SHOW_COMPLETED');
const empty = Actions.CLEAR_TODOS(); // No argument required if value type is {}To get the TypeScript type representing the entire union defined by your unionize object, use the UnionOf utility type and pass the typeof your union object.
import { UnionOf } from 'unionize';
const Actions = unionize({
ADD_TODO: ofType<{ id: string; text: string }>(),
CLEAR_TODOS: {},
});
type Action = UnionOf<typeof Actions>;The match method allows you to handle union members using pure functions instead of switch statements.
It supports two usage patterns:
Actions.match(action, { ... })const handler = Actions.match({ ... }); handler(action);To handle the remaining cases, provide a default key in the cases object. If default is not provided, the match must be exhaustive.
const todosReducer = (state: Todo[] = [], action: Action) =>
Actions.match(action, {
ADD_TODO: ({ id, text }) => [...state, { id, text, completed: false }],
TOGGLE_TODO: ({ id }) =>
state.map(todo => todo.id === id ? { ...todo, completed: !todo.completed } : todo),
default: a => state
});
// Curried version for reusable handlers
const getIdFromAction = Actions.match({
ADD_TODO: ({ id }) => id,
TOGGLE_TODO: ({ id }) => id,
default: a => { throw new Error(`Action type ${a.type} does not have an associated id`); },
});
const id = getIdFromAction(Actions.ADD_TODO({ id: '123', text: 'hi' }));Use unionize to define a set of tagged union types by providing a mapping of tags to value types.
ofType<T>() to define the value type for a tag.{} for "empty" types (tags with no payload)."tag".value property name in the config, the value type is intersected directly with the tag (e.g., { tag: 'TYPE' } & { id: string }). In this case, the value type must be an object type.value property name in the config, the value will be nested under that key (e.g., { type: 'TYPE', payload: { id: string } }). This is required if your value type is a primitive (like a string union).import { unionize, ofType } from 'unionize';
// Default behavior (tag: 'tag')
const Actions = unionize({
ADD_TODO: ofType<{ id: string; text: string }>(),
CLEAR_TODOS: {},
});
// Custom property names (e.g., FSA compliant)
const FSA_Actions = unionize({
ADD_TODO: ofType<{ id: string; text: string }>(),
SET_VISIBILITY_FILTER: ofType<'SHOW_ALL' | 'SHOW_ACTIVE' | 'SHOW_COMPLETED'>(),
}, {
tag: 'type',
value: 'payload',
});The as property provides a way to cast an unknown action to a specific union member. Warning: This will throw an error at runtime if the action does not match the specified tag.
// Throws if someAction is not an ADD_TODO
const { id, text } = Actions.as.ADD_TODO(someAction);The transform method is a shorthand for converting a union type to itself. It allows you to handle a subset of cases and leave the rest unchanged.
It can be used in two ways:
const transformer = Union.transform({ ... }); const result = transformer(original);Union.transform(original, { ... })const Light = unionize({ On: ofType<{ percentage: number }>(), Off: {} });
// Returns a function that transforms the state
const dim = Light.transform({
On: prev => Light.On({ percentage: prev.percentage / 2 }),
});
const on = Light.On({ percentage: 100 });
const dimmed = dim(on); // Result is Light.On({ percentage: 50 })
// Direct usage
const toggled = Light.transform(on, {
On: () => Light.Off(),
Off: () => Light.On({ percentage: 50 }),
});The is property provides type guard functions for each tag, which are useful for filtering streams (like RxJS Observables) or narrowing types in conditional blocks.
// Example with an Observable
const epic = (action$: Observable<Action>) => action$
.filter(Actions.is.ADD_TODO)
.mergeMap(({ payload }) => console.log(payload.text));When you create a union using unionize, you can extract the underlying types using several helper types. This is useful when writing functions that accept the union or its components.
UnionOf<U>: The type of the union itself (the variants).RecordOf<U>: The original record type used to define the union.TaggedRecordOf<U>: The type of the object containing the tags.TagsOf<U>: The union of the tag names (keys).import { unionize, UnionOf, RecordOf } from 'unionize';
const MyUnion = unionize({
a: ofType<{ x: number }>()
});
type AllVariants = UnionOf<typeof MyUnion>;
type OriginalRecord = RecordOf<typeof MyUnion>;The as property provides type-safe casting functions for each tag defined in the union. If the variant passed to the function does not match the expected tag, it will throw a runtime error.
as[TagName](variant) returns the specific type associated with that tag.
const circle = Shape.circle({ radius: 5 });
// Returns the specific type for 'circle'
const c = Shape.as.circle(circle);
// Throws: Error: Attempted to cast 'square' as 'circle'
Shape.as.circle(Shape.square({ side: 10 }));The unionize() function creates a tagged union from a record mapping tags to value types. It provides variant constructors, type predicates (is), type casting (as), pattern matching (match), and transformations (transform).
tag: The name of the property used to identify the variant (defaults to 'tag').value: The name of the property that holds the variant's data. If not specified, the data is merged directly into the object itself.When value is not specified, the variant data is spread into the object alongside the tag.
When value is specified, the variant data is nested under that property name.
import { unionize, ofType } from 'unionize';
// Define the union
const Shape = unionize({
circle: ofType<{ radius: number }>(),
square: ofType<{ side: number }>(),
}, { tag: 'type' });
// 1. Using Creators
const myCircle = Shape.circle({ radius: 10 }); // { type: 'circle', radius: 10 }
// 2. Using Type Predicates (is)
if (Shape.is.circle(myCircle)) {
console.log(myCircle.radius); // Type safe
}
// 3. Using Pattern Matching (match)
const area = Shape.match({
circle: (c) => Math.PI * c.radius ** 2,
square: (s) => s.side ** 2,
default: (v) => 0
})(myCircle);
// 4. Using Type Casting (as)
try {
const s = Shape.as.square(myCircle);
} catch (e) {
// Throws error if tag doesn't match
}The match function allows you to perform exhaustive pattern matching on a union variant. It supports two calling signatures:
match(cases, variant): Returns the result of the matching case.match(cases)(variant): Returns a function that accepts a variant.default property must not be used.default handler for any variant that doesn't match the provided keys.Note: If you use a default case, the handler receives the full variant object. If you do not use a default case, the handlers receive the extracted value (if using the value config option) or the object itself (if using the default merged behavior).
// Example of exhaustive match
const result = Shape.match({
circle: (c) => `Circle with ${c.radius}`,
square: (s) => `Square with ${s.side}`
})(myCircle);
// Example of match with default
const resultWithDefault = Shape.match({
circle: (c) => `Circle`,
default: (v) => `Unknown variant: ${v.tag}`
})(myCircle);