Repository navigation
Description
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 () => {}); // okOpen 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: () => {} }); // errorOpen 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 }>; // falseNot 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
- 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). isEmptyObjectnarrows toRecord<PropertyKey, never>, which rejects an emptyinterface.Writable<DeepReadonly<X>>isn'tX—DeepReadonlyhasReadonlyMap/ReadonlySetbranchesWritabledoesn'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
[-]es-toolkit/types: which utilities to add next (measured across 60 OSS repos)[/-][+]es-toolkit/types: what to add next[/+]niksy commentedon Aug 15, 2026
Voting for
JsonValuesince it should be pretty common in codebases with API communication and unknown responses.