@practical-fp/union-types
Version:
A Typescript library for creating discriminating union types.
453 lines (420 loc) • 12.6 kB
text/typescript
/**
* A type which discriminates on {@link tag}
* when used in a union with other instances of this type.
*
* @example
* type Union =
* | Variant<"1">
* | Variant<"2", number>
*/
export interface Variant<Tag extends string = string, Value = undefined> {
readonly tag: Tag
readonly value: Value
}
/**
* Utility type which allows any {@link Variant} to be assigned to it.
*/
export type AnyVariant = Variant<string, unknown>
/**
* Creates a new {@link Variant} instance whose value is undefined.
* @param tag
*/
export function tag<Tag extends string>(tag: Tag): Variant<Tag>
/**
* Creates a new {@link Variant} instance.
* @param tag
* @param value
*/
export function tag<Tag extends string, Value>(tag: Tag, value: Value): Variant<Tag, Value>
export function tag(tag: string, value?: unknown): AnyVariant {
return {
tag,
value,
}
}
/**
* Extracts the value form a @link Variant} instance.
* @param variant
*/
export function untag<Value>(variant: Variant<string, Value>): Value {
return variant.value
}
/**
* Utility type for extracting the possible values for {@link Variant#tag}
* from a union of {@link Variant}s.
*
* @example
* type Union =
* | Variant<"1">
* | Variant<"2">
*
* // Equals: "1" | "2"
* type UnionTags = Tags<Union>
*/
export type Tags<Var extends AnyVariant> = Var["tag"]
/**
* Utility type for extracting the possible types for {@link Variant#value}
* from a union of {@link Variant}s.
*
* @example
* type Union =
* | Variant<"1", string>
* | Variant<"2", number>
*
* // Equals: string | number
* type UnionValues = Values<Union>
*/
export type Values<Var extends AnyVariant> = Var["value"]
/**
* Utility type for narrowing down a union of {@link Variant}s based on their tags.
*
* @example
* type Union =
* | Variant<"1", 1>
* | Variant<"2", 2>
* | Variant<"3", 3>
*
* // Equals: Variant<"1", 1> | Variant<"3", 3>
* type Narrowed = Narrow<Union, "1" | "3">
*/
export type Narrow<Var extends AnyVariant, Tag extends Tags<Var>> = Extract<
Var,
Variant<Tag, unknown>
>
/**
* Type guard for narrowing down the type of a {@link Variant}.
* @param variant
* @param tag
* @example
* type Union =
* | Variant<"1", number>
* | Variant<"2", string>
*
* function doSomething(union: Union) {
* // union.value has type number | string
*
* if (hasTag(union, "1")) {
* // union.value has type number now
* }
* }
*/
export function hasTag<Var extends AnyVariant, Tag extends Tags<Var>>(
variant: Var,
tag: Tag,
): variant is Narrow<Var, Tag> {
return variant.tag === tag
}
/**
* Type of a function which narrows down the type of a given {@link Variant}.
*/
export type Predicate<Tag extends string> = <Var extends AnyVariant>(
variant: Var,
) => variant is Narrow<Var, Tag>
/**
* Factory function for creating a type guard which narrows down the type of a {@link Variant}.
* @param tag
* @example
* type Union =
* | Variant<"1", number>
* | Variant<"2", string>
*
* function doSomething(list: Union[]) {
* // filtered has type Variant<"1", number>[]
* const filtered = list.filter(predicate("1"))
* }
*/
export function predicate<Tag extends string>(tag: Tag): Predicate<Tag> {
return <Var extends AnyVariant>(variant: Var): variant is Narrow<Var, Tag> =>
hasTag(variant, tag)
}
/**
* Symbol for declaring a wildcard case in a {@link match} expression.
*/
export const WILDCARD = Symbol("Match Wildcard")
/**
* Utility type for ensuring that a {@link matchExhaustive} expression covers all cases.
*/
export type CasesExhaustive<Var extends AnyVariant, Ret = unknown> = {
[Tag in Tags<Var>]: (value: Values<Narrow<Var, Tag>>) => Ret
}
/**
* Utility type for enabling a {@link matchWildcard} expression to cover only some cases,
* as long as, a wildcard case is declared for matching the remaining cases.
*/
export type CasesWildcard<Var extends AnyVariant, Ret = unknown> = Partial<
CasesExhaustive<Var, Ret>
> & { [WILDCARD]: () => Ret }
/**
* Utility type for ensuring that a {@link match} expression either covers all cases,
* or contains a wildcard for matching the remaining cases.
*/
export type Cases<Var extends AnyVariant, Ret = unknown> =
| CasesExhaustive<Var, Ret>
| CasesWildcard<Var, Ret>
/**
* Utility type for inferring the return type of a {@link match} expression.
*/
export type CasesReturn<Var extends AnyVariant, C extends Cases<Var>> = C extends Cases<
Var,
infer Ret
>
? Ret
: never
/**
* Function for matching on the tag of a {@link Variant}.
* All possible cases need to be covered, unless a wildcard case is present.
* @param variant
* @param cases
* @example
* type Union =
* | Variant<"Num", number>
* | Variant<"Str", string>
* | Variant<"Bool", boolean>
*
* function doSomething(union: Union) {
* return match(union, {
* Num: number => number * number,
* Str: string => `Hello, ${string}!`,
* Bool: boolean => !boolean,
* })
* }
*
* function doSomethingElse(union: Union) {
* return match(union, {
* Str: string => `Hello, ${string}!`,
* [WILDCARD]: () => "Hello there!",
* })
* }
* @deprecated Use {@link matchExhaustive} or {@link matchWildcard} instead.
*/
export function match<Var extends AnyVariant, C extends Cases<Var>>(
variant: Var,
cases: C,
): CasesReturn<Var, C> {
const tag = variant.tag
if (typeof (cases as any)[tag] === "function") {
return (cases as any)[tag](variant.value)
} else if (typeof (cases as any)[WILDCARD] === "function") {
return (cases as any)[WILDCARD]()
}
throw new Error(`No case matched tag ${tag}.`)
}
/**
* Helper type to restrict the possible keys of a type.
*
* This is useful for {@link matchExhaustive} and {@link matchWildcard} where the cases argument
* needs to be generic to infer the correct return type.
* However, due to the argument being generic it is allowed to pass extra properties.
* Passing extra arguments is probably a spelling mistake.
* Therefore, we restrict the properties by setting extra properties to never.
*
* Typescript 4.2 will show a nice hint asking whether you've misspelled the property name.
*/
export type ValidateProperties<T, AllowedProperties extends keyof T> = {
[_ in Exclude<keyof T, AllowedProperties>]: never
}
/**
* Function for matching on the tag of a {@link Variant}.
* All possible cases need to be covered.
* @param variant
* @param cases
* @example
* type Union =
* | Variant<"Num", number>
* | Variant<"Str", string>
* | Variant<"Bool", boolean>
*
* function doSomething(union: Union) {
* return matchExhaustive(union, {
* Num: number => number * number,
* Str: string => `Hello, ${string}!`,
* Bool: boolean => !boolean,
* })
* }
*/
export function matchExhaustive<Var extends AnyVariant, Cases extends CasesExhaustive<Var>>(
variant: Var,
cases: Cases & ValidateProperties<Cases, keyof CasesExhaustive<Var>>,
): CasesReturn<Var, Cases> {
return match(variant, cases)
}
/**
* Function for matching on the tag of a {@link Variant}.
* Not all cases need to be covered, a wildcard case needs to be present.
* @param variant
* @param cases
* @example
* type Union =
* | Variant<"Num", number>
* | Variant<"Str", string>
* | Variant<"Bool", boolean>
*
* function doSomething(union: Union) {
* return matchWildcard(union, {
* Str: string => `Hello, ${string}!`,
* [WILDCARD]: () => "Hello there!",
* })
* }
*/
export function matchWildcard<Var extends AnyVariant, Cases extends CasesWildcard<Var>>(
variant: Var,
cases: Cases & ValidateProperties<Cases, keyof CasesWildcard<Var>>,
): CasesReturn<Var, Cases> {
return match(variant, cases)
}
/**
* Utility function for asserting that all cases have been covered.
* @param variant
* @example
* type Union =
* | Variant<"1", string>
* | Variant<"2", number>
*
* function doSomething(union: Union) {
* switch(union.tag) {
* case "1":
* alert(union.value)
* break
* case "2":
* alert(union.value.toFixed(0))
* break
* default:
* // compile error if we've forgotten a case
* assertNever(union)
* }
* }
*/
export function assertNever(variant: never): never {
throw new Error("Unreachable state reached!")
}
/**
* Type which specifies the constructor for a variant type.
*/
export type Constructor<Tag extends string, Value> = <T extends Value>(
value: Value extends undefined ? T | void : T,
) => Variant<Tag, T>
/**
* Type which specifies the strict constructor for a variant type.
* It does not support generics.
*/
export type StrictConstructor<Tag extends string, Value> = (
value: Value extends undefined ? Value | void : Value,
) => Variant<Tag, Value>
/**
* Type which specifies the extra properties which are attached to a constructor.
*/
export interface ConstructorExtra<Tag extends string> {
tag: Tag
is: Predicate<Tag>
}
/**
* Type which specifies the constructor for a variant type with attached type guard.
*/
export type ConstructorWithExtra<Tag extends string, Value> = Constructor<Tag, Value> &
ConstructorExtra<Tag>
/**
* Type which specifies the strict constructor for a variant type with attached type guard.
* It does not support generics.
*/
export type StrictConstructorWithExtra<Tag extends string, Value> = StrictConstructor<Tag, Value> &
ConstructorExtra<Tag>
/**
* Function for creating a constructor for the given variant.
*
* In case the variant type uses unconstrained generics,
* pass unknown as its type arguments.
*
* In case the variant type uses constrained generics,
* pass the constraint type as its type arguments.
*
* Use {@link impl} instead if your environment has support for {@link Proxy}.
*
* @example
* type Result<T, E> =
* | Variant<"Ok", T>
* | Variant<"Err", E>
*
* const Ok = constructor<Result<unknown, unknown>, "Ok">("Ok")
* const Err = constructor<Result<unknown, unknown>, "Err">("Err")
*
* let result: Result<number, string>
* result = Ok(42)
* result = Err("Something went wrong")
*
* Ok.is(result) // false
* Err.is(result) // true
*
* Ok.tag // "Ok"
* Err.tag // "Err"
*/
export function constructor<Var extends AnyVariant, Tag extends Tags<Var>>(
tagName: Tag,
): ConstructorWithExtra<Tag, Values<Narrow<Var, Tag>>> {
function constructor<T>(value: T) {
return tag(tagName, value)
}
constructor.tag = tagName
constructor.is = predicate(tagName)
return constructor
}
/**
* Same as {@link constructor}, but does not support generics.
* @param tagName
*/
export function strictConstructor<Var extends AnyVariant, Tag extends Tags<Var>>(
tagName: Tag,
): StrictConstructorWithExtra<Tag, Values<Narrow<Var, Tag>>> {
return constructor(tagName)
}
/**
* Type which specifies constructors and type guards for a variant type.
*/
export type Impl<Var extends AnyVariant> = {
[Tag in Tags<Var>]: ConstructorWithExtra<Tag, Values<Narrow<Var, Tag>>>
}
/**
* Type which specifies strict constructors and type guards for a variant type.
* It does not support generics.
*/
export type StrictImpl<Var extends AnyVariant> = {
[Tag in Tags<Var>]: StrictConstructorWithExtra<Tag, Values<Narrow<Var, Tag>>>
}
/**
* Function for generating an implementation for the given variants.
*
* In case the variant type uses unconstrained generics,
* pass unknown as its type arguments.
*
* In case the variant type uses constrained generics,
* pass the constraint type as its type arguments.
*
* @example
* type Result<T, E> =
* | Variant<"Ok", T>
* | Variant<"Err", E>
*
* const {Ok, Err} = impl<Result<unknown, unknown>>()
*
* let result: Result<number, string>
* result = Ok(42)
* result = Err("Something went wrong")
*
* Ok.is(result) // false
* Err.is(result) // true
*
* Ok.tag // "Ok"
* Err.tag // "Err"
*/
export function impl<Var extends AnyVariant>(): Impl<Var> {
return new Proxy({} as Impl<Var>, {
get: <Tag extends keyof Impl<Var>>(_: Impl<Var>, tagName: Tag) => {
return constructor<Var, Tag>(tagName)
},
})
}
/**
* Same as {@link impl}, but does not support generics.
*/
export function strictImpl<Var extends AnyVariant>(): StrictImpl<Var> {
return impl()
}