Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to match nested Keys in Typescript

Tags:

typescript

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?

like image 480
IronSean Avatar asked Jun 14 '19 21:06

IronSean


2 Answers

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
like image 130
Gerrit0 Avatar answered Oct 17 '22 02:10

Gerrit0


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.

like image 3
Mu-Tsun Tsai Avatar answered Oct 17 '22 02:10

Mu-Tsun Tsai