I've created a simple nameOf
helper for use with typescript.
function nameOf<T>(name: Extract<keyof T, string>) {
return name;
}
In places where a function expects a string representing the key of a property, but isn't appropriately typed I can use it like so:expectsKey(nameOf<MyObject>("someMyObjectProperty"))
. This means even if I don't control expectsKey(key: string)
I can get some type checking on the strings I pass to it. That way if a property on MyObject
is renamed, the nameOf()
call will show an error the normal function wouldn't detect until execution.
Is it possible to extend this to nested elements?
ie, some way to do a typecheck of nameOf<MyComplexObject>('someProperty[2].someInnerProperty')
to ensure it matches the sturcture of type MyComplexObject
?
Directly? No. You can't create concatenate properties to create a new string in TS, which would be required for this functionality.
However, you can get similar functionality with a mapped type.
interface MyObject {
prop: {
arr?: { inner: boolean }[]
other: string
method(): void
}
}
// Creates [A, ...B] if B is an array, otherwise never
type Join<A, B> = B extends any[]
? ((a: A, ...b: B) => any) extends ((...args: infer U) => any) ? U : never
: never
// Creates a union of tuples descending into an object.
type NamesOf<T> = {
[K in keyof T]: [K] | Join<K, NamesOf<NonNullable<T[K]>>>
}[keyof T]
// ok
const keys: NamesOf<MyObject> = ['prop']
const keys2: NamesOf<MyObject> = ['prop', 'arr', 1, 'inner']
// error, as expected
const keys3: NamesOf<MyObject> = [] // Need at least one prop
const keys4: NamesOf<MyObject> = ['other'] // Wrong level!
// Technically this maybe should be allowed...
const keys5: NamesOf<MyObject> = ['prop', 'other', 'toString']
You can't directly use this within your nameOf
function. This is an error as the type instantiation will be detected as possibly infinite.
declare function nameOf<T>(path: NamesOf<T>): string
However, you can use NamesOf
if you make TypeScript defer its resolution until you are actually using the function. You can do this fairly easily either by including it as a generic default, or by wrapping the argument type in a conditional (which provides the additional benefit of preventing the use of nameOf
when the type is a primitive)
interface MyObject {
prop: {
arr?: { inner: boolean }[]
other: string
method(): void
},
prop2: {
something: number
}
}
// Creates [A, ...B] if B is an array, otherwise never
type Join<A, B> = B extends any[]
? ((a: A, ...b: B) => any) extends ((...args: infer U) => any) ? U : never
: never
// Creates a union of tuples descending into an object.
type NamesOf<T> = {
[K in keyof T]: [K] | Join<K, NamesOf<NonNullable<T[K]>>>
}[keyof T]
declare function nameOf<T>(path: T extends object ? NamesOf<T> : never): string
const a = nameOf<MyObject>(['prop', 'other']) // Ok
const c = nameOf<MyObject>(['prop', 'arr', 3, 'inner']) // Ok
const b = nameOf<MyObject>(['prop', 'something']) // Error, should be prop2
If you go the other route and include the path in the generic constraint, be sure to mark the path as both defaulting to the path (so you don't have to specify it when using the function) and as extending NameOf<T>
(so that users of nameOf
can't lie about the keys)
declare function nameOf<T, P extends NamesOf<T> = NamesOf<T>>(path: P): string
The answer given by @Gerrit0 is really cool, but like he said, his answer could easily lead to looping because the nature of his method is to navigate all possible substructure at once (so if any type repeats somewhere along the line, you fall into endless loop). I came up with a different approach with the following nameHelper
class. To use it, you call new nameHelper<myClassName>()
and start chaining .prop("PropertyName" | index)
, and finally use .name
to get the names joined by dots or brackets. TypeScript will detect any wrong property name if there's one. I hope this is what you're looking for.
class nameHelper<T> {
private trace: PropertyKey[];
constructor(...trace: PropertyKey[]) {
this.trace = trace;
}
public prop<K extends keyof T>(name: K & PropertyKey) {
this.trace.push(name);
return new nameHelper<T[K]>(...this.trace);
}
public get name() { return this.trace.join(".").replace(/\.(\d+)\./g, "[$1]."); }
}
class SampleClass {
public Prop: InnerClass;
public Arr: InnerClass[];
}
class InnerClass {
public Inner: {
num: number
}
}
console.log(new nameHelper<SampleClass>().prop("Prop").prop("Inner").prop("num").name);
// "Prop.Inner.num"
console.log(new nameHelper<SampleClass>().prop("Arr").prop(2).prop("Inner").prop("num").name);
// "Arr[2].Inner.num"
console.log(new nameHelper<SampleClass>().prop("Prop").prop("Other").name);
// error here
I shall put your modified version here for future reference. I made two further modification:
INameHelper
interface, just to prevent VS Code from showing a lot of unreadable stuffs when I hover my mouse above the key()
method.path()
to a getter just to save two more characters of typing.interface INameHelper<T> {
readonly path: string;
key: <K extends keyof T>(name: K & PropertyKey) => INameHelper<T[K]>;
}
function nameHelper<T>(...pathKeys: PropertyKey[]): INameHelper<T> {
return {
get path() { return pathKeys.join('.').replace(/\.(\d+)\./g, '[$1].')},
key: function <K extends keyof T>(name: K & PropertyKey) {
pathKeys.push(name);
return nameHelper<T[K]>(...pathKeys);
},
};
}
class SampleClass {
public Prop: InnerClass;
public Arr: InnerClass[];
}
class InnerClass {
public Inner: {
num: number
}
}
console.log(nameHelper<SampleClass>().key("Prop").key("Inner").key("num").path);
// "Prop.Inner.num"
console.log(nameHelper<SampleClass>().key("Arr").key(2).key("Inner").key("num").path);
// "Arr[2].Inner.num"
console.log(nameHelper<SampleClass>().key("Prop").key("Other").path);
// error here
The tricky part of your latest request is that, as far as I know, there is no way to extract the string literal type from a give string variable without using generics, and we cannot have a function that looks like function<T, K extends keyof T>
but also use it in such a way that only T
is specified; either we don't specify both T
and K
and let TypeScript to infer them automatically, or we have to specify both.
Since I need the string literal type, I use the former approach and write the following pathfinder
function you wish for. You can pass either an object instance or a constructor function to it as the first parameter, and everything else works as desired. You cannot pass random parameters to it.
Also I have changed the definition of INameHelper so that even .key()
is not needed, and now the nameHelper
function is scoped and cannot be access directly.
interface INameHelper<T> {
readonly path: string;
<K extends keyof T>(name: K & PropertyKey): INameHelper<T[K]>;
}
function pathfinder<S, K extends keyof S>(object: S | (new () => S), key: K) {
function nameHelper<T>(pathKeys: PropertyKey[]): INameHelper<T> {
let key = function <K extends keyof T>(name: K & PropertyKey) {
pathKeys.push(name);
return nameHelper<T[K]>(pathKeys);
};
Object.defineProperty(key, "path", {
value: pathKeys.join('.').replace(/\.(\d+)/g, '[$1]'),
writable: false
});
return <INameHelper<T>>key;
}
return nameHelper<S[K]>([key]);
}
class SampleClass {
public Prop: InnerClass;
public Arr: InnerClass[];
}
class InnerClass {
public Inner: {
num: number
}
}
var obj = {
test: {
ok: 123
}
};
console.log(pathfinder(SampleClass, "Prop")("Inner")("num").path);
// "Prop.Inner.num"
console.log(pathfinder(SampleClass, "Arr")(2)("Inner")("num").path);
// "Arr[2].Inner.num"
console.log(pathfinder(obj, "test")("ok").path);
// "test.ok"
// works with instance as well
console.log(pathfinder(SampleClass, "Prop")("Other").path);
// error here
console.log(pathfinder(InnerClass, "Prop").path);
// also error here
There is however one imperfection still: by writing things this way, pathfinder
cannot be used to test static members of a class, because if you pass a class name to it, TypeScript will automatically treat it as a class constructor function instead of an object.
If you need to use it also on static class members, then you should remove | (new () => S)
in the definition, and whenever you are testing non-static members, you'll need to pass either an instance or SampleClass.prototype
to it.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With