7

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?

IronSean
  • 1,520
  • 17
  • 31

2 Answers2

7

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
Gerrit0
  • 7,955
  • 3
  • 25
  • 32
  • 1
    This looks really promising, however in practice I'm having some trouble with my editor spinning forever trying to resolve the types. I'm going to keep playing with it – IronSean Jun 18 '19 at 14:04
  • @IronSean could you provide the context? I suspect you are sending TS into an infinite loop somewhere, which this type makes unfortunately easy to do, or your types are complex enough that the resolved type is too big. TS3.4 had a regression which caused really poor union performance, so if you are using that be sure to upgrade to 3.5. – Gerrit0 Jun 19 '19 at 01:49
  • I'm on TS3.5.2, I found one issue was an infinite type I created while implementing a function to render it down to a string containing the accessor name. (unfortunately I'm using a library that takes pathnames to nested objects as strings with no type checking.) Your second exampling using P extends also doesn't seem to play nicely with my project in vSCode, complaining of infinite nesting. I can get the first version working, but the performance is limited. multiple minutes for each hovered type to resolve. – IronSean Jun 25 '19 at 13:05
  • Actually even stranger, pasting the answer in immediately works... but after modifying the file a little typescript seems to get locked up and it stops being able to resolve quickly. This could be exposing something deeper in my codebase. – IronSean Jun 25 '19 at 13:13
  • @IronSean By saying "pasting the answer in immediately works", do you mean you can actually compile it without error, or just nothing wrong happen to VS Code? If it's the latter then it could be simply because VS Code haven't start performing type checking yet. I did notice similar behavior when I was testing his answer. – Mu-Tsun Tsai Jun 25 '19 at 13:32
  • Hi @Gerrit0 is it possible to get return type of nameOf function to be type by path for example in const c = nameOf(['prop', 'arr', 3, 'inner']) 'c' should be boolean – Arthur Jan 13 '20 at 21:07
3

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

Update

I shall put your modified version here for future reference. I made two further modification:

  1. I defined the INameHelper interface, just to prevent VS Code from showing a lot of unreadable stuffs when I hover my mouse above the key() method.
  2. I change 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

Update2

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.

Community
  • 1
  • 1
Mu-Tsun Tsai
  • 2,348
  • 1
  • 12
  • 25
  • 1
    I iterated on this to use a functional instead of class format, and eliminate the need for the new keyword on each use: ``` function nameHelper(...pathKeys: PropertyKey[]) { return { path: () => pathKeys.join('.').replace(/\.(\d+)\./g, '[$1].'), key: function(name: K & PropertyKey) { pathKeys.push(name); return nameHelper(...pathKeys); }, }; }``` – IronSean Jun 25 '19 at 13:56
  • @IronSean Awesome! I like your version very much. Glad that the concept works for you. – Mu-Tsun Tsai Jun 25 '19 at 14:35
  • Thanks for the additional changes. The last thing I'm trying to see is if I can expose a safe starting method like this: export const pathfinder = (key: U) => { return nameHelper(key); }; Unfortunately it resolves `T[U]` to `never` when attempting to use it. I just wanted to prevent calling nameHelper with some form of input when you start, but thought I'd try passing the firm param with it when I did so. – IronSean Jun 25 '19 at 18:14
  • @IronSean I updated my answer again; hope that I finally meet all your demands :) – Mu-Tsun Tsai Jun 26 '19 at 02:12