unionize

repository·master·Indexed 19 days ago

https://github.com/pelotom/unionize

A 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.

Tokens
3.1K
Snippets
13
Records
13
Agent score
15%

What's inside unionize

  1. Use element factories to create union members

    master

    The 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 {}
  2. Extract inferred union types with UnionOf

    master

    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>;
  3. Handle union members with match()

    master

    The match method allows you to handle union members using pure functions instead of switch statements.

    It supports two usage patterns:

    1. Directly: Actions.match(action, { ... })
    2. Curried: 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' }));
  4. Define tagged unions with unionize()

    master

    Use unionize to define a set of tagged union types by providing a mapping of tags to value types.

    • Use ofType<T>() to define the value type for a tag.
    • Use {} for "empty" types (tags with no payload).
    • By default, the tag property is named "tag".
    • If you omit the 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.
    • If you provide a 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',
    });
  5. Use type casts with Actions.as

    master

    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);
  6. Transform union types with transform()

    master

    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:

    1. Curried: const transformer = Union.transform({ ... }); const result = transformer(original);
    2. Direct: 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 }),
    });
  7. Use type guards with Actions.is

    master

    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));
  8. Extract types from a Unionized object

    master

    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>;
  9. Cast union variants with as()

    master

    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 }));
  10. Create a tagged union with unionize()

    master

    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).

    Configuration Options

    • 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.

    Usage Patterns

    1. Merged Data (Default)

    When value is not specified, the variant data is spread into the object alongside the tag.

    2. Wrapped Data

    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
    }
  11. Pattern match with match()

    master

    The match function allows you to perform exhaustive pattern matching on a union variant. It supports two calling signatures:

    1. match(cases, variant): Returns the result of the matching case.
    2. match(cases)(variant): Returns a function that accepts a variant.

    Match Cases

    • Exhaustive: Provide a handler for every tag in the record. The default property must not be used.
    • With Default: Provide handlers for some tags and a 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);