@afitzek/unenum
v1.0.0
Published
0kb, Rust-like Enums for TypeScript. Forked just for publishing.
Downloads
3
Maintainers
Readme
unenum
A 0kb, Rust-like Enum/ADT mechanism for TypeScript with zero runtime requirements.
Overview • Installation • Enum
• Patterns • Result
•
Future
• match
•
safely
Overview
TypeScript should have a more versitile and ergonomic Enum/ADT mechanism that
feels like native utility, as opposed its
limited,
misused,
and redundant built-in enum
keyword which can be mostly replaced with a plain key-value mapping object
using as const
.
Introducing unenum
; a Rust-inspired, discriminable Enum/ADT type generic,
featuring:
- Zero dependencies;
unenum
is extremely lightweight. - Zero runtime requirements;
unenum
can be completely compiled away -- no runtime or bundle size cost. Enum
variants that can define custom per-instance data; impossible with native TypeScriptenum
s.
unenum
wants to feel like a native TypeScript utility type, like a
pattern, rather than a library:
Enum
s are defined astype
statements; instead of factory functions.Enum
s are instantiated with plain object{ ... }
syntax; instead of constructors.Enum
s can be consumed (and narrowed) with plainif
statements; instead of imported match utilities.
Here's an example of unenum
's Enum
compared with Rust's
enum
:
Installation
npm install unenum
For Applications (Global):
import "unenum/global";
For Libraries (Imported):
import type { Enum, ... } from "unenum";
Enum<Variants>
Creates a union of mutually exclusive, discriminable variants.
import "unenum/global.enum"; // global
import type { Enum } from "unenum"; // imported
type Foo = Enum<{
A: undefined;
B: { b: string };
C: { c: number };
}>;
-> | { is: "A" }
| { is: "B"; b: string }
| { is: "C"; c: number }
Enum.Keys<Enum>
Infers all possible variants' keys of the given Enum.
type Foo = Enum<{ A: undefined; B: { b: string }; C: { c: number } }>;
Enum.Keys<Foo>
-> "A" | "B" | "C"
Enum.Values<Enum>
Infers all possible variants' values of the given Enum.
type Foo = Enum<{ A: undefined; B: { b: string }; C: { c: number } }>;
Enum.Values<Foo>
-> | { b: string }
| { c: number }
Enum.Props<Enum, All?>
Infers only common variants' properties' names of the given Enum. If All
is
true
, then all variants' properties' names are inferred.
type Foo = Enum<{ A: undefined; B: { x: string }; C: { x: string; y: number } }>;
Enum.Props<Foo>
-> "x"
Enum.Props<Foo, true>
-> "x" | "y"
Enum.Pick<Enum, VariantKeys>
Narrows a given Enum by including only the given variants by key.
type Foo = Enum<{ A: undefined; B: { b: string }; C: { c: number } }>;
Enum.Pick<Foo, "A" | "C">
-> | { is: "A" }
| { is: "C"; c: number }
Enum.Omit<Enum, VariantKeys>
Narrows a given Enum by excluding only the given variants by key.
type Foo = Enum<{ A: undefined; B: { b: string }; C: { c: number } }>;
Enum.Omit<Foo, "A" | "C">
-> | { is: "B"; b: string }
Patterns
Enum
s are disciminated
unions
that use is
as a property to differentiate between variants. TypeScript
supports type
narrowing by
analysing control flow statements like if
and return
to determine when
certain Enum
variants are accessible, allowing for safe property access.
If a function's return type is not explicitly annotated it will be inferred
instead, which will lead to inaccurate Enum
return types. Explicitly
specifying an Enum
(e.g. Foo
) as a return type will ensure that a function
returns a valid Enum
variant and provides autocompletion to help instantiate
Enum
variants with all their properties (e.g. return { is: "B", b: "..."
}
).
With explicit return types (recommended)
type Foo = Enum<{ A: undefined; B: { b: string }; C: { c: number } }>;
function getFoo(value: string | number): Foo {
if (!value) {
return { is: "A" };
}
if (typeof value === "string") {
return { is: "B", b: value };
}
return { is: "C", c: value };
}
Note
If you need to limit the range of possible
Enum
variants that can be returned (or used as a value/parameter/etc), useEnum.Pick
orEnum.Omit
.
With if
statements (recommended)
type Foo = Enum<{ A: undefined; B: { b: string }; C: { c: number } }>;
const foo: Foo = { ... };
if (foo.is === "A") {
return 123;
}
if (foo.is === "B") {
return foo.b === "" ? "empty" : "abc";
}
return null;
Note
if
statements are the most universal and native way to handleEnum
variants without any dependencies.
With match
function (dependency)
See match
.
type Foo = Enum<{ A: undefined; B: { b: string }; C: { c: number } }>;
const foo: Foo = { ... };
import { match } from "unenum";
match(foo, {
A: () => 123,
B: ({ b }) => b === "" ? "empty" : "abc",
C: () => null,
});
Note
Using the
match
utility will makeunenum
a runtime dependency with a non-0kb bundle-size cost instead of being a type-only utility. However,match
is tiny and very helpful for reducing complexity of conditional variable assignments instead of needing to write one-off functions, IIFEs, or ternary expressions.
With ternary expressions
type Foo = Enum<{ A: undefined; B: { b: string }; C: { c: number } }>;
const foo: Foo = { ... };
foo.is === "A"
? 123
: foo.is === "B"
? (foo.b === "" ? "empty" : "abc")
: null;
Note
Ternary expressions are often criticised for poor readibility, where sufficiently complex and nested expression (such as the above example) are strong candidates for refactoring into functions that may use
if
statements and the early-return pattern to cleanly narrow down anEnum
's variants.
With switch
statements
type Foo = Enum<{ A: undefined; B: { b: string }; C: { c: number } }>;
const foo: Foo = { ... }
switch (foo.is) {
case "A": {
return 123;
}
case "B": {
return foo.b === "" ? "empty" : "abc";
}
default: {
return null;
}
}
Note
switch
statements are severely limited because they can only branch based on anEnum
'sis
variant discriminant.if
statements allow for more versitile conditional expressions that may accomodate evaluating other variables or even properties on theEnum
variant itself (e.g.if (foo.is === "B" && foo.b === "hello") ...
).
Included Enums
Result<Value?, Error?>
Represents either success value
(Ok
) or failure error
(Error
).
Result
uses value?: never
and error?: never
to allow for shorthand access
to .value
or .error
if you want to safely default to undefined
if either
property is not available.
import "unenum/global.result"; // global
import type { Result } from "unenum"; // imported
Result
-> | { is: "Ok"; value: unknown; error?: never }
| { is: "Error"; error: unknown; value?: never }
Result<number>
-> | { is: "Ok"; value: number; error?: never }
| { is: "Error"; error: unknown; value?: never }
Result<number, "FetchError">
-> | { is: "Ok"; value: number; error?: never }
| { is: "Error"; error: "FetchError"; value?: never }
const getUser = async (name: string): Promise<Result<User, "NotFound">> => {
return { is: "Ok", value: user };
return { is: "Error", error: "NotFound" };
}
const $user = await getUser("foo");
if ($user.is === "Error") { return ... }
const user = $user.value;
const $user = await getUser("foo");
const userOrUndefined = $user.value;
const userOrUndefined = $user.is === "Ok" ? $user.value : undefined;
const $user = await getUser("foo");
const userOrDefault = $user.value ?? defaultUser;
const userOrDefault = $user.is === "Ok" ? $user.value : defaultUser;
Based on Rust's
Result
enum.
Note
You may find it useful to name variables for container-like
Enum
s (likeResult
s andFuture
s) with a$
prefix (e.g.$user
) before unwrapping the desired value into non-prefixed value (e.g.const user = $user.value
).
Future<ValueOrEnum?>
Represents an asynchronous value
that is either loading (Pending
) or
resolved (Ready
). If defined with an Enum
type, Future
will omit its
Ready
variant in favour of the "non-pending" Enum
's variants.
Future
uses value?: never
to allow for shorthand access to .value
if you
want to safely default to undefined
if it is not available. If using with an
Enum
type, all its common properties will be extended as ?: never
properties on the Pending
variant to allow for shorthand undefined
access
also. (See Enum.Props
.)
import type { Future } from "unenum"; // imported
Future
-> | { is: "Pending"; value?: never }
| { is: "Ready"; value: unknown }
Future<string>
-> | { is: "Pending"; value?: never }
| { is: "Ready"; value: string }
Future<Result<number>>
-> | { is: "Pending"; value?: never; error?: never }
| { is: "Ok"; value: number; error?: never }
| { is: "Error"; error: unknown; value?: never }
const useRemoteUser = (name: string): Future<Result<User, "NotFound">> => {
return { is: "Pending" };
return { is: "Ok", value: user };
return { is: "Error", error: "NotFound" };
};
const $user = useRemoteUser("foo");
if ($user.is === "Pending") { return <Loading />; }
if ($user.is === "Error") { return <Error />; }
const user = $user.value;
return <View user={user} />;
const $user = useRemoteUser("foo");
const userOrUndefined = $user.value;
const userOrUndefined = $user.is === "Ok" ? $user.value : undefined;
const $user = useRemoteUser("foo");
const userOrDefault = $user.value ?? defaultUser;
const userOrDefault = $user.is === "Ok" ? $user.value : defaultUser;
Based on Rust's
Future
trait and
Poll
enum.
Utils
match(value, matcher) -> ...
Uses a given Enum
value
to execute its corresponding variants' matcher
function and return its result. Use match.orUndefined(...)
or
match.orDefault(...)
if you want to match against only a subset of variants.
import { match } from "unenum"; // dependency
type Foo = Enum<{ A: undefined; B: { b: string }; C: { c: number } }>;
const foo: Foo = ...
// all cases
match(foo, {
A: () => null,
B: ({ b }) => b,
C: ({ c }) => c,
})
-> null | string | number
// some cases or undefined
match.orUndefined(foo, {
A: () => null,
B: ({ b }) => b,
})
-> null | string | undefined
// some cases or default
match.orDefault(
foo,
{ A: () => null },
($) => $.is === "B" ? true : false
)
-> null | string | boolean
safely(fn) -> Result
Executes a given function and returns a Result
that wraps its normal return
value as Ok
and any thrown errors as Error
. Supports async/Promise
returns.
import { safely } from "unenum"; // dependency
safely(() => JSON.stringify(...))
-> Result<string>
safely(() => JSON.parse(...))
-> Result<unknown>
safely(() => fetch("/endpoint").then(res => res.json() as Data))
-> Promise<Result<Data>>