Skip to content

es-toolkit/types: what to add next #1986

Closed

Description

@dayongkr

Context

es-toolkit/types shipped in v1.50.0 with six utilities (#1818). This issue tracks what to add next.

I went through a number of well-known OSS projects to see which utility types they keep declaring on their own. The list below is ordered by how often they showed up and how hard they are to get right by hand.

Priority 1

UnionToIntersection<T> — keyof on a union only keeps the common keys.

type Keys = keyof ({ a: 1 } | { b: 2 });                     // never
type Keys = keyof UnionToIntersection<{ a: 1 } | { b: 2 }>;  // 'a' | 'b'

Also folds a variadic merge result back into one object. Several projects link an external blog post in a comment to explain the contravariant infer trick.

IsAny<T> — any satisfies both branches of a conditional, so it has to be caught first.

type Serialized<T> = T extends Date ? string : T;
type Serialized<any>;  // matches every branch at once, output becomes meaningless

type Safe<T> = IsAny<T> extends true ? any : Serialized<T>;

Everything else in this family (IsUnion, IsUnknown, IsTuple) builds on this one.

DistributiveOmit<T, K> — built-in Omit flattens a union into its common keys.

type Shape = { kind: 'circle'; r: number } | { kind: 'rect'; w: number };

Omit<Shape, 'kind'>;              // {} — r and w are both gone
DistributiveOmit<Shape, 'kind'>;  // { r: number } | { w: number }

Every project that has it wrote the same name and the same body.

Priority 2

MaybePromise<T> — lets a callback be sync or async.

function onEvent(handler: () => MaybePromise<void>) {}

onEvent(() => {});        // ok
onEvent(async () => {});  // ok

Open question: Promise or PromiseLike. Note our own retry/withTimeout/attemptAsync currently reject sync callbacks.

Primitive — recursion base case, and the narrowing type of a runtime guard.

type Paths<T> = T extends Primitive ? never : /* recurse */;

isPrimitive already ships this exact union anonymously in its signature, so naming it is the whole change.

JsonValue (with JsonObject / JsonArray / JsonPrimitive)

function send(body: JsonValue) {}

send({ a: [1, 'x', null] });  // ok
send({ fn: () => {} });       // error

Open question: isJSONValue currently narrows to a different type, so we'd need to decide whether to lift it.

IsNever<T> — naive versions return never instead of a boolean. Low value alone, useful as a set with IsAny.

Needs a decision before adding

LiteralUnion — keeps autocomplete while still accepting any string.

type Color = LiteralUnion<'red' | 'blue'>;
// suggests 'red' | 'blue', still accepts 'rebeccapurple'

Constraining the base type catches typos but rejects inputs type-fest accepts. Same name with a different contract is awkward when both packages are used together — so either copy type-fest's definition as is, or use a different name.

IsEqual — we already export a runtime isEqual(a, b), and a type differing only in case would mean something else:

isEqual({ a: 1 }, { a: 1 });                   // true
IsEqual<{ a: 1 } & { b: 2 }, { a: 1; b: 2 }>;  // false

Not planning to add

Type Why not
AnyFunction Three bodies are each right for a different job (=> any as a constraint, => unknown for deep mappers, (...never) => any as a top type). Worth an _internal alias, not public API.
Branded Needs unique symbol, which stops working across duplicate installs. type-fest solved it with a separate package; a subpath export can't.
EmptyObject Implementations in the wild are mutually incompatible, and the common use is & {} — which Simplify already does.
NoInfer Built into TS 5.4.
Merge Widely declared, but no agreement on the name or the semantics.

Notes on the #1818 roadmap

Previous TODO was JsonValue → UnionToIntersection → LiteralUnion → Brand → DeepRequired. Suggested changes: UnionToIntersection first (everything builds on it, and JsonValue needs the isJSONValue decision), drop Brand, and DeepRequired barely showed up while DistributiveOmit and IsAny weren't on the list at all.

One thing to settle: all six current types are single-argument transformers. These candidates open two new shapes — vocabulary (Primitive, JsonValue) and predicates (IsAny, IsNever) — and each invites follow-up requests. Probably best to open one per release with the boundary written down.

Worth doing regardless

  1. Brand-preservation regression tests for DeepPartial/DeepReadonly/Simplify (cf. [Regression]: page.evaluate() arg of a branded primitive type (string & { brand }) no longer type-checks since 1.62 microsoft/playwright#42000).
  2. isEmptyObject narrows to Record<PropertyKey, never>, which rejects an empty interface.
  3. Writable<DeepReadonly<X>> isn't X — DeepReadonly has ReadonlyMap/ReadonlySet branches Writable doesn't reverse.

Call for input

If you keep re-declaring a type this entrypoint doesn't have, please comment with the definition and a usage example — variants matter (Promise vs PromiseLike, distributive or not), and a real usage site is what decides the order above. Disagreement with the skips is welcome too.

Activity

  1. changed the title [-]es-toolkit/types: which utilities to add next (measured across 60 OSS repos)[/-] [+]es-toolkit/types: what to add next[/+] on Aug 2, 2026
  2. niksy commented on Aug 15, 2026

    @niksy

    Voting for JsonValue since it should be pretty common in codebases with API communication and unknown responses.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions