Our goal is to transform the exists(obj, paths)
function into a specialized user-defined type guard function that operates as a generic. When obj
has a generic type of T
and paths
holds a generic type of K[]
, we aim for a return value of true
to refine obj
to a subtype of T
with non-non-nullish properties at the specified paths in K
.
In order to achieve this, we need to create a variant of the Record<K, V>
utility type called NestedRecord<K, V>
. This new type should interpret K
as a collection of dotted paths rather than plain keys. Here's how it should work:
type Example = NestedRecord<
"z.y.x" | "z.w.v" | "u.t" | "u.t.s" | "r.q" | "p",
Date
>;
/* Output: {
z: {
y: { x: Date; };
w: { v: Date; };
};
u: { t: Date & { s: Date; }; };
r: { q: Date; };
p: Date;
}*/
For accurate representation, we want the call signature of exists()
to be structured like this:
declare function exists<T extends object, K extends string>(
obj: T, paths: K[]
): obj is T & NestedRecord<K, {}>;
Now our attention shifts towards defining NestedRecord<K, V>
.
We can implement it using a technique that involves key remapping in mapped types along with template literal types to disassemble the keys within K
. The keys within NestedRecord<K, V>
should represent segments before the initial dots in K
, or any sections of K
lacking dots. Similarly, the values enclosed in NestedRecord<K, V>
should correspond to V
for dotless parts of
K</code, aligned with <a href="https://www.typescriptlang.org/docs/handbook/2/objects.html#intersection-types" rel="nofollow noreferrer">intersections</a> with <code>NestedRecord<K, V>
for segments in
K
post the primary dot associated with the present key.
Note that NestedRecord<never, V>
needs to translate to unknown
instead of {}
to eliminate inconveniences stemming from numerous intersections with {}
in the final type.
A test on the definition proves its intent with the provided Example
.
To put exists()
into practice, here's one conceivable approach:
function exists<T extends object, K extends string>(
obj: T, paths: K[]
): obj is T & NestedRecord<K, {}> {
return paths.every(path => path.split(".").reduce((accumulator, key) => (accumulator ?? accumulator[key]), obj) != null);
}
The utilization of the every()
and reduce()
array methods ensures validation of non-nullish values within object
across all paths prescribed by paths
.
An application of this function on an illustrative object
manifest itself as follows:
object;
// ^? var object: Foo
if (exists(object, ["a.ii", "c"])) {
object;
// ^? var object: Foo & { a: { ii: {}; }; c: {}; }
let value = object.a.ii;
// ^? let value: number
console.log(value); // 1
}
The success outcome showcases effective narrowing of object
from Foo
to
Foo & { a: { ii: {}; }; c: {}; }
. Consequently, exploration of
object.a.ii
after narrowing reflects a type equivalent to
(number | undefined) & {}
, which simplifies to
number
, fulfilling the defined criteria.
Playground link to code