disc-union
v1.2.0
Published
A set utilities for working with discriminated unions in typescript
Downloads
7
Maintainers
Readme
disc-union
A set utilities for working with discriminated unions in typescript
Installation
npm i disc-union
Introduction
Discriminated unions are a powerful and expressive way of modelling data in Typescript, but working with them often requires a lot of boilerplate code. This library is a collection of utilities for creating and handling discriminated union types in a way that is more elegant and concise.
We aim to keep this library as generic as possible, so these functions are designed work well with any discriminated union, no matter how they are constructed.
Note: This library uses Typescript's template literal types, and so requires Typescript version >= 4.1
Motivating Example
State of an API call in React
// Model the data by providing constructor functions for each type
const ApiResult = discUnion({
success: (posts: Post[]) => ({ posts }),
error: (message: string) => ({ message }),
loading: () => ({}),
});
/**
* use DiscUnionOf to extract the type of the discriminated union
* ApiResult =
* | { type: 'success', post: Posts[] }
* | { type: 'error', message: string }
* | { type: 'loading' }
*/
type ApiResultType = DiscUnionOf<typeof ApiResult>;
// discUnion returns constructor functions for each type
const { success, error, loading } = ApiResult;
const fetchPosts = () =>
fetch('https://jsonplaceholder.typicode.com/posts')
.then((response) => response.json() as Promise<Post[]>)
.then(posts => success(posts))
.catch(() => error('Something went wrong'));
function Posts() {
const [apiState, setApiState] = useState<ApiResultType>(loading());
useEffect(() => {
fetchPosts().then(setApiState)
}, [])
// Static methods are attached to each constructor to conveniently work with types
const numPosts = success.get(apiState)?.posts.length ?? '---';
if (error.is(apiState)) console.error('Oops!', apiState.message);
// Use the match function to exhaustively match each type
const pageContent = match(apiState, {
success: ({ posts }) => posts.map(p => <div key={p.id}>{p.body}</div>),
error: ({ message }) => <span>Error: {message}</span>,
loading: () => <div>Loading...</div>
});
return <div>
<span>{numPosts} Posts</span>
{pageContent}
</div>;
}
Functions
discUnion
(constructors: Constructors, typeKey?: string, prefix?: string) => ConstructorsWithType
discUnion
takes an object whose values are constructor functions for your types, and whose keys are the names of the corresponding types. It wraps the results of the functions to include the type names automatically. For example:
const Dinosaur = discUnion({
tRex: (armSize: 'normal' | 'smol') => ({ armSize }),
pterodactyl: (wingspan: number) => ({ wingspan }),
stegosaurus: (numPlates: number) => ({ numPlates })
});
Dinosaur.pterodactyl(16) // { type: 'pterodactyl', wingspan: 16 }
discUnion
also optionally takes a typeKey
argument, which allows you to change the key of the discriminant (as does every other function in this library). And you can prefix the type names if you'd like by providing a string as prefix
const Dinosaur = discUnion({
tRex: (armSize: 'normal' | 'smol') => ({ armSize }),
pterodactyl: (wingspan: number) => ({ wingspan }),
stegosaurus: (numPlates: number) => ({ numPlates })
}, 'species', '@dinosaur/');
Dinosaur.pterodactyl(16); // { species: '@dinosaur/pterodactyl', wingspan: 16 }
// The type constructors have a `key` property attached to easily access these values.
// This comes in handy with prefixed keys or keys with special characters.
match(dino, {
[Dinosaur.tRex.key]: t => p.armSize.length,
[Dinosaur.pterodactyl.key]: p => p.wingspan
[Dinosaur.stegosaurus.key]: s => p.numPlates
}, 'species');
Constructor static functions
For convenience, the constructor functions have several "static" functions attached to it: is
, get
, map
, and validate
. These all are equivalent to the top level functions of the same names (see below), but with type
and typeKey
arguments are already bound. Here are some examples of their usage:
const { tRex, pterodactyl, stegosaurus } = Dinosaur;
if (tRex.is(someDino)) {
console.log(tRex.armsize);
}
const wingspan = pterodactyl.get(someDino)?.wingspan ?? 0;
const wingspan = pterodactyl.validate(someDino).wingspan;
const changedToStego = tRex.map(someDino, tRex => stegosaurus(armSize.length));
match
(handlers: Handlers, typeKey?: string) => Return of matched handler
(handlers: Partial<Handlers>, otherwise: Handler, typeKey?: string) => Return of matched handler
match
takes an object of handlers whose keys correspond to the possible keys of the input type, and it returns the result of the matched handler. It is exhaustive by default, but if you include an otherwise
handler as the second argument then handlers may be partial.
// Full match
const describeDino = (dino: Dinosaur) => match(dino, {
tRex: tRex => `A T-Rex with ${tRex.armSize} arms`,
pterodactyl: pt => `A pterodactyl with a ${pt.wingspan} foot wingspan`,
stegosaurus: steg => `A stegosaurus with ${steg.numPlates} plates along its back`,
});
describeDino(Dinosaur.tRex('smol')) // A T-Rex with smol arms
// Partial match
const describeDino = (dino: Dinosaur) => match(dino, {
tRex: tRex => `A T-Rex with ${tRex.armSize} arms`,
}, () => 'Some other dinosaur');
describeDino(Dinosaur.stegosaurus(7)) // Some other dinosaur
is
(type: string, obj: DiscUnionType, typeKey?: string) => obj is Narrowed<DiscUnionType, type>
is
is a convenience function for narrowing the type of a discriminated union type. It is equivalent to obj.type === 'something'
.
if (is('tRex', unknownDino)) {
console.log(unknownDino.armSize);
}
get
(type: string, obj: DiscUnionType, typeKey?: string) => Narrowed<DiscUnionType, type> | null
get
returns the object if it matches the type, and null otherwise
const wingspan = get('pterodactyl' unknownDino)?.wingspan ?? 0;
validate
(type: string, obj: DiscUnionType, typeKey?: string) => Narrowed<DiscUnionType, type>
validate
returns the object if it matches the type, and throws an error otherwise. For when you are 100% sure about the type and it would be a fatal error otherwise.
const wingspan = validate('pterodactyl' unknownDino).wingspan;
createType
(type: string, obj: object, typeKey?: string) => WithType<typeof obj, type, typeKey>
createType
is a convenience function for constructing objects with types. It can helpful if you are creating your constructors manually instead of using discUnion (which is necessary for generics - see below).
const apiResult = <T>(data: T) => createType('apiResult', { data })>;
const result = apiResult({ userId: 4, userName: 'LeeroyJenkins' });
factory
(factoryTypeKey: string) => LibraryFunctions
By default, all functions in this library assume the discriminant property to be type
. Although this can be overridden with the typeKey
property, you can also use factory
to create a new set of functions that use whichever default you'd like. For example, if your convention is to use the kind
property:
const {
discUnion,
match,
is,
// ...
} = factory('kind');
const someDuck = { kind: 'duck', greeting: 'quack' };
is('duck', someDuck); // true
Utility Types
DiscUnionOf
DiscUnionOf<T>
Takes an object of constructors, and returns a discriminated union based on their return type. Used on the return of discUnion
Narrow
Narrow<T, Key, TypeKey?>
Takes a discriminated union type, and narrows it based on a key.
Without
Without<T, Key, TypeKey?>
Opposite of Narrow
. Takes a discriminated union type, and excludes the types with matching keys.
Keys
Keys<T, TypeKey?>
Takes a discriminated union type, and returns a list of all possible keys as a union of strings
Generics
Unfortunately, due to limitations of Typescript's type inference, there is no way create a generic discriminated union with discUnion
. You can stil use the library to work with these types, but you'll have to write some of the boilerplate yourself to make it work. As an example, here is how you can create an Option
type that can hold any value (or not):
// Leave out any types with generic parameters
const _Option = discUnion({
none: () => ({}),
});
// Write the generic type and constructor yourself (you can still use createType)
export type Some<T> = { type: 'some', value: T };
const some = <T>(value: T) => createType('some', { value });
// Wrap DiscUnionOf with your own generic type
export type OptionType<T> = DiscUnionOf<typeof _Option> | Some<T>;
// Spread the new constructor into the object
export const Option = {
..._Option,
some,
};
// Correctly infers maybeNum: OptionType<number>
const maybeNum = Math.random() > 0.5 ? Option.some(4) : Option.none();
This exported Option object works just like one that would come from discUnion
, except it is generic. The only thing missing is the static properties attached to the constructor, if you would like to add that, you can wrap your function in the attachExtras
function like so
const some = attachExtras(
<T>(value: T) => createType('some', { value }),
'some',
);