Reputation: 6039
I'm creating a function like Lodash's at(). I have typing working if the user passes in tuples like this:
at(obj, ['key1'] as const, ['key2', 'key3'] as const)
I want the user to be able to call the function naturally, without tricks like "as const
". Can it be done? Here is the craziness I have so far:
type PropertyAtPath<T, Path extends readonly any[]> = Path extends []
? T
: Path extends readonly [infer First, ...infer Rest]
? First extends keyof T
? PropertyAtPath<T[First], Rest>
: undefined
: unknown;
type At<T, Paths extends ReadonlyArray<ReadonlyArray<any>>> = {
[I in keyof Paths]: Paths[I] extends readonly any[]
? PropertyAtPath<T, Paths[I]>
: never;
};
declare function at<T, Paths extends ReadonlyArray<ReadonlyArray<any>>>(
object: T,
...paths: Paths
): At<T, Paths>;
Upvotes: 3
Views: 2247
Reputation: 23885
In case someone stumbles upon this in the future.
If you just want to have the function infer a tuple instead of an array without using as const
, there are multiple options.
The preferred option is to use a variadic tuple type:
function fn<T extends string[]>(tuple: [...T]) { return tuple }
const r = fn(["a", "b", "c"])
// ^? const r: ["a", "b", "c"]
There is also this alternative:
function fn<T extends string[] | [string]>(tuple: T) { return tuple }
This behaves identical to the first example in most cases. However I encountered some differences in the past where the second solution works but the first one does not.
Upvotes: 4
Reputation: 33091
First of all, you need to validate second argument, to avoid passing invalid object paths.
Here, here, here and here, in my blog you can find an explanation of how this code works.
First three links are from stackoverflow. I have provided explanation in comments.
type Structure = {
foo: {
a: [1, 'hello'],
b: 2,
}
bar: {
c: 3,
d: 4,
}
}
declare var data: Structure;
type Values<T> = T[keyof T]
type Elem = string;
type Acc = Record<string, any>
/**
* Just like Array.prototype.reduce predicate/callback
* Receives accumulator and current element
* - if element extends one of accumulators keys -> return acc[elem]
* - otherwise return accumulator
*/
type Callback<Accumulator extends Acc, El extends Elem> =
El extends keyof Accumulator ? Accumulator[El] : Accumulator
type Reducer<
Keys extends Elem,
Accumulator extends Acc = {}
> =
/**
* If Keys extends a string with dot
*/
Keys extends `${infer Prop}.${infer Rest}`
/**
* - Call Reducer recursively with last property
*/
? Reducer<Rest, Callback<Accumulator, Prop>>
/**
* - Otherwise obtain whole property
*/
: Keys extends `${infer Last}`
? Callback<Accumulator, Last>
: never
{
type Test1 = Reducer<'foo.a', Structure> // 1
type Test2 = Reducer<'bar.d', Structure> // 4
}
/**
* Compute all possible property combinations
*/
type KeysUnion<T, Cache extends string = ''> =
/**
* If T extends string | number | symbol -> return Cache, this is the end
*/
T extends PropertyKey ? Cache : {
/**
* Otherwise, iterate through keys of T, because T is an object
*/
[P in keyof T]:
/**
* Check if property extends string
*/
P extends string
/**
* Check if it is the first call of this utility,
* because Cache is empty
*/
? Cache extends ''
/**
* If it is a first call,
* call recursively itself, go one level down - T[P] and initialize Cache - `${P}`
*/
? KeysUnion<T[P], `${P}`>
/**
* If it is not first call of KeysUnion and not the last
* Unionize Cache with recursive call, go one level dow and update Cache
*/
: Cache | KeysUnion<T[P], `${Cache}.${P}`>
: never
}[keyof T]
{
//"foo" | "bar" | "foo.a" | "foo.b" | "bar.c" | "bar.d"
type Test1 = KeysUnion<Structure>
}
type ExtractPath<T extends string> = Extract<T, string>
type Mapper<Obj, Paths extends ExtractPath<KeysUnion<Obj>>[]> = {
[Prop in keyof Paths]: Reducer<Paths[Prop] & string, Obj>
}
const at = <
Obj,
Key extends ExtractPath<KeysUnion<Obj>> & string,
Keys extends Key[]
>(obj: Obj, keys: [...Keys]): Mapper<Obj, Keys> =>
null as any
type Result = Mapper<Structure, ['foo.a.1', 'bar.c']>
const lookup = at(data, ['foo.a.0', 'bar.c']) // [1, 3]
Above code expects obj
to be fully infered, I mean obj
should be as const
.
If you want to handle arrays
and empty tuples you might want to use this implementation:
type Values<T> = T[keyof T]
{
// 1 | "John"
type _ = Values<{ age: 1, name: 'John' }>
}
type IsNever<T> = [T] extends [never] ? true : false;
{
type _ = IsNever<never> // true
type __ = IsNever<true> // false
}
type IsTuple<T> =
(T extends Array<any> ?
(T['length'] extends number
? (number extends T['length']
? false
: true)
: true)
: false)
{
type _ = IsTuple<[1, 2]> // true
type __ = IsTuple<number[]> // false
type ___ = IsTuple<{ length: 2 }> // false
}
type IsEmptyTuple<T extends Array<any>> = T['length'] extends 0 ? true : false
{
type _ = IsEmptyTuple<[]> // true
type __ = IsEmptyTuple<[1]> // false
type ___ = IsEmptyTuple<number[]> // false
}
/**
* If Cache is empty return Prop without dot,
* to avoid ".user"
*/
type Concat<
Cache extends PropertyKey[],
Prop extends string | number | symbol
> =
Cache extends []
? [Prop]
: [...Cache, Prop]
/**
* Simple iteration through object properties
*/
type HandleObject<Obj, Cache extends PropertyKey[]> = {
[Prop in keyof Obj]:
| Cache
// concat previous Cacha and Prop
| Concat<Cache, Prop>
// with next Cache and Prop
| Path<Obj[Prop], Concat<Cache, Prop>>
}[keyof Obj]
type Path<Obj, Cache extends PropertyKey[] = []> =
(Obj extends PropertyKey
// return Cache
? Cache
// if Obj is Array (can be array, tuple, empty tuple)
: (Obj extends Array<any>
// and is tuple
? (IsTuple<Obj> extends true
// and tuple is empty
? (IsEmptyTuple<Obj> extends true
// call recursively Path with `-1` as an allowed index
? Path<PropertyKey, Concat<Cache, -1>>
// if tuple is not empty we can handle it as regular object
: HandleObject<Obj, Cache>)
// if Obj is regular array call Path with union of all elements
: Path<Obj[number], [...Cache, `${number}`]>
)
// if Obj is neither Array nor Tuple nor Primitive - treat is as object
: HandleObject<Obj, Cache>
)
)
type Reducer<Obj, Props extends Array<PropertyKey>> =
Props extends []
? Obj
: (Props extends [infer Fst, ...infer Tail]
? (Tail extends string[]
? (
Obj extends Array<any>
? (
Fst extends `${number}`
? Reducer<Obj[number], Tail>
: never)
: (Fst extends keyof Obj
? Reducer<Obj[Fst], Tail>
: never
)
)
: never
)
: never
)
type Validation<T> = IsNever<T> extends true ? [never] : []
const at = <
Obj,
Keys extends string[]
>(obj: Obj, keys: [...Keys], ...validation: Validation<Reducer<Obj, Keys>>): Reducer<Obj, [...Keys]> =>
null as any
type Structure = {
empty: [],
tuple: [1, 2, 3],
array: { age: Array<{ surname: string }> }[]
}
declare const data: Structure
/**
* Tests
*/
// [1, 2, 3]
const _ = at(data, ['tuple'])
// const __: {
// age: Array<{
// surname: string;
// }>;
// }
const __ = at(data, ['array', '0'])
// const ___: {
// surname: string;
// }[]
const ___ = at(data, ['array', '2', 'age'])
// const ____: {
// surname: string;
// }
const ____ = at(data, ['array', '0', 'age', '2'])
/**
* Expected never
*/
{
const _ = at(data, ['tupl'],) // error
const __ = at(data, ['array', 'w']) // error
}
Upvotes: 3