A tagged unions code generation library for discriminating tastes
Install with npm install tagged-ts
or yarn add tagged-ts
As far as I'm aware, this is currently the only TypeScript code generation library of this kind that fully supports working with union types using generics.
This means you can generate constructors, guards, a function simulating pattern matching, etc. for types like
type Maybe<A> = Just<A> | Nothing
and
type Result<E, A> = Failure<E> | Success<A>
without specializing the generic type params upon generation.
The generated utility functions will automatically have roughly the same type signatures that you'd write if you were defining them manually.
This means less boilerplate & less repetition in your codebase.
Most libraries of this kind represent nullary data constructors, those which take no arguments, as thunks, functions which take no arguments and return the type of the tagged union. This isn't the end of the world, but these libraries use thunks instead of constants only because it's much easier to generate thunks, and it requires less run-time information.
However, most libraries offering hand written sum types model nullary constructors as constants, because this is both more convenient and more similar to what you find in languages that feature tagged unions as a native feature, like Haskell, PureScript, Elm, ReasonML/OCaml, F#, etc....
I think using constants is more ideal, and tagged-ts
allows you to.
While many people are happy with a one-size-fits-all approach for libraries like this, others desire more configurability. tagged-ts
offers a few sensible default functions to select from as well as a more configurable function for customization.
Quick notes on each of the available generation functions:
-
mkTaggedUnion
- The default, go-to generation function provided by the library
- Discriminant key:
'tag'
- Nullary constructors mode:
'constant'
- Use
mkTaggedUnionBasic
instead if you're okay with nullary constructors being functions instead of constants and/or you desire less boilerplate
-
mkTaggedUnionBasic
- An alternate version of
mkTaggedUnion
which represents nullary constructors as functions (thunks) instead of constants, requiring less boilerplate - Discriminant key:
'tag'
- Nullary constructors mode:
'thunk'
- An alternate version of
-
mkTaggedUnionRedux
- A generation function preconfigured to the right defaults for working with Redux-style actions & action creators
- Discriminant key:
'type'
- Nullary constructors mode:
'thunk'
-
mkTaggedUnionCustom
- The most configurable version of this function offered by the library, most useful to those who don't mind passing in a config each time & want maximum flexibility
- Discriminant key: Whatever string you want to use (Configurable)
- Nullary constructors mode:
'constant' | 'thunk'
(Configurable)
Fully supporting polymorphic type constructors and correctly propagating the generic type params through all layers of the code generation without losing type information is difficult. In fact, I don't think it's possible to support sum types that take more than a single type param using the conventional techniques employed by other tagged unions libraries.
The trick to making it work is taking a page out of the fp-ts
playbook and using type-level maps and "Declaration Merging" (Module Augmentation & Interface Merging) to our advantage. Declaration Merging: https://www.typescriptlang.org/docs/handbook/declaration-merging.html
In fp-ts
, the modification of type-level maps is used to facilitate a sort of type system hack that allows TypeScript to model higher kinded types & higher kinded polymorphism. For those familiar with ReasonML/OCaml, this technique is very similar to the approach using "open"/extensible GADTs outlined in the "Lightweight higher-kinded polymorphism" paper.
In tagged-ts
, the type-level maps represent a mapping from a unique identifier, a string or symbol, to a type constructor specification, referred to as a Spec
in the source code. This allows us to correctly plumb generics through all the internal machinery of the library. There are 5 type-level maps in total, one for each Kind:
/**
* A type-level Map for nullary type constructors,
*
* or, in other words, those of kind: `*`
*
* @since 0.2.0
*/
export interface TypeConstructorRegistry0 {}
/**
* A type-level Map for type constructors taking 1 type param,
*
* or, in other words, those of kind: `* -> *`
*
* @since 0.2.0
*/
export interface TypeConstructorRegistry1<A> {}
/**
* A type-level Map for type constructors taking 2 type params,
*
* or, in other words, those of kind: `* -> * -> *`
*
* @since 0.2.0
*/
export interface TypeConstructorRegistry2<E, A> {}
/**
* A type-level Map for type constructors taking 3 type params,
*
* or, in other words, those of kind: `* -> * -> * -> *`
*
* @since 0.2.0
*/
export interface TypeConstructorRegistry3<R, E, A> {}
/**
* A type-level Map for type constructors taking 4 type params,
*
* or, in other words, those of kind: `* -> * -> * -> * -> *`
*
* @since 0.2.0
*/
export interface TypeConstructorRegistry4<S, R, E, A> {}
/**
* A union of all type constructor registries
*
* @since 0.2.0
*/
export type TypeConstructorRegistry =
| TypeConstructorRegistry0
| TypeConstructorRegistry1<unknown>
| TypeConstructorRegistry2<unknown, unknown>
| TypeConstructorRegistry3<unknown, unknown, unknown>
| TypeConstructorRegistry4<unknown, unknown, unknown, unknown>
We use declaration merging to add new type constructor Specs
to the appropriate TypeConstructorRegistry
type-level map.
Here's an example of what constructing the Spec
for Maybe<A>
and adding it to TypeConstructorRegistry1<A>
would look like:
import { TypeConstructorRegistry1 } from 'tagged-ts/lib/Registry'
type Nothing = { readonly tag: 'Nothing' }
type Just<A> = {
readonly tag: 'Just'
readonly value: A
}
type Maybe<A> = Just<A> | Nothing
interface MaybeSpec<A> {
readonly type: Maybe<A>
readonly data: {
readonly Just: Just<A>
readonly Nothing: Nothing
}
}
declare module 'tagged-ts/lib/Registry' {
interface TypeConstructorRegistry1<A> {
readonly Maybe: MaybeSpec<A>
}
}
Alternatively, instead of writing the Spec
by hand, we can construct the Spec
using the MkTypeConstructorSpec
type-level utility, which will not allow you to make a mistake.
import { MkTypeConstructorSpec } from 'tagged-ts/lib/Registry'
type MaybeSpec<A> = MkTypeConstructorSpec<
// gets assigned to the `type` field of the spec
Maybe<A>,
// used to ensure the correct types are being passed in the next type param
'tag',
// gets assigned to the `data` field of the spec
{
readonly Just: Just<A>
readonly Nothing: Nothing
}
>
After adding an entry to the appropriate registry, we can use the generation functions.
Example of using the default mkTaggedUnion
:
import { __, mkTaggedUnion } from 'tagged-ts'
// `__` is used as a general purpose placeholder for when keys are needed at
// run-time, but values are not
export const Maybe = mkTaggedUnion<'Maybe'>()({
Just: { tag: __, value: __ },
Nothing: { tag: __ },
})
// annotation required because Maybe.Nothing could be assigned to any Maybe<A>
const numMayA: Maybe<number> = Maybe.Nothing
// const numMayB: Maybe<number>
const numMayB = Maybe.Just({ value: 0 })
// Maybe.Just inference before called
// (property) Just: <A>(fields: {
// readonly value: A;
// }) => Maybe<A>
// Maybe.Just inference after given value
// (property) Just: <number>(fields: {
// readonly value: number;
// }) => Maybe<number>
Instead, we could use the more configurable mkTaggedCustom
. Example:
import { __, mkTaggedUnionCustom, thunk } from 'tagged-ts'
import { TypeConstructorRegistry1 } from 'tagged-ts/lib/Registry'
// using '__TYPE__' as the discriminant key now
type Nope = { readonly __TYPE__: 'Nope' }
type Yup<A> = {
readonly __TYPE__: 'Yup'
readonly value: A
}
type Perhaps<A> = Yup<A> | Nope
type PerhapsSpec<A> = MkTypeConstructorSpec<
Perhaps<A>,
'__TYPE__',
{
readonly Yup: Yup<A>
readonly Nope: Nope
}
>
declare module 'tagged-ts/lib/Registry' {
interface TypeConstructorRegistry1<A> {
readonly Perhaps: PerhapsSpec<A>
}
}
// configure discriminant key & nullary constructors mode
const customConfig = {
discriminantKey: '__TYPE__',
nullaryConstructorsMode: thunk // or just 'thunk' written manually,
} as const
// less boilerplate run-time information is needed in 'thunk' mode
export const Perhaps = mkTaggedUnionCustom<'Perhaps'>()(customConfig, {
Yup: __,
Nope: __,
})
// nullary constructors are thunks now
// perhapsA: Perhaps<string>
const perhapsA = Perhaps.Nope<string>()
// perhapsB: Perhaps<string>
const perhapsB = Perhaps.Yup({ value: 'indeed' })
Examples of using the other functions generated for you:
// Using the `match` function:
// const transformed: { result: number }
const transformed = Perhaps.match(perhapsB, {
Nope: () => ({ result: 0 }),
Yup: ({ value }) => ({ result: value.length }),
})
// Using type guards:
// const yes: boolean
const yes = Perhaps.is.Nope(perhapsA)
// const no: boolean
const no = Perhaps.is.Yup(perhapsA)
// const alsoNo: boolean
const alsoNo = Perhaps.is.memberOfUnion({ somethingElse: 'unknown' })
Like the fp-ts
ecosystem libraries, this library is using doc-ts
to generate documentation from JSDoc comments in the source code. These generated docs are not currently being hosted anywhere, but they are availble in the docs
directory. I may host them somewhere in the future.
Generated docs:
Better documentation and more features are coming soon.