Typescript keyof extra type condition

6.4k views Asked by At

I want pass two generic condition for pass array type field name but not accepted second condition.

This my method declaration and this no problem.

firstOrDefault<K extends keyof T>(predicate?: (item: T) => boolean, recursiveItem?: K): T;

Above method declaration is working but I want pass only Array type in recurviseItem field.

I'm trying this method declaration but doesn't work.

firstOrDefault<K extends keyof T & T[]>(predicate?: (item: T) => boolean, recursiveItem?: K): T

How can solve this problem?

Sample Code

let departments : IDepartment[] = [
    {
        name: 'manager',
        subDepartments: [
            {
                name: 'accountant'
            }
        ]
    }
]

// This my method declaration and this code worked but you can pass name and subDepartments field pass for recursiveItem parameter but i want only T[] type field pass so only subDepartments.
let department = departments.firstOrDefault(d => d.name == 'accountant', 'subDepartments')
console.log(department)

interface Array<T> {
    firstOrDefault<K extends keyof T>(predicate?: (item: T) => boolean, recursiveItem?: K): T;
}

Array.prototype.firstOrDefault = function(this, predicate, recursiveItem) {
    if (!predicate)
        return this.length ? this[0] : null;
    for (var i = 0; i < this.length; i++) {
        let item = this[i]
        if (predicate(item))
            return item
        if (recursiveItem) {
            let subItems = item[recursiveItem]
            if (Array.isArray(subItems)) {
                var res = subItems.firstOrDefault(predicate, recursiveItem)
                if (res)
                    return res
            }
        }
    }
    return null;
}

interface IDepartment {
    name?: string,
    subDepartments?: IDepartment[]
}
2

There are 2 answers

0
Leo On BEST ANSWER

Try this type definition

type ArrayProperties<T, I> = { [K in keyof T]: T[K] extends Array<I> ? K : never }[keyof T]

class A {
    arr1: number[];
    arr2: string[];
    str: string;
    num: number;
    func() {}
}

let allArr: ArrayProperties<A, any>; // "arr1" | "arr2"
let numArr: ArrayProperties<A, number>; // "arr1"

so firstOrDefault would look like that (I put ArrayProperties<T, T> to restrict recursiveItem only for recursive type i.e. only properties of type IDepartment[] could be used, however you can put ArrayProperties<T, any> if you want to accept any array)

function firstOrDefault<T>(predicate?: (item: T) => boolean, recursiveItem?: ArrayProperties<T, T>): T { }

With your example

interface IDepartment {
    name: string;
    subDepartments?: IDepartment[];
}

let departments : IDepartment[] = [
    {
        name: 'manager',
        subDepartments: [
            {
                name: 'accountant'
            }
       ]
    }
]

let a = firstOrDefault(((d: IDepartment) => d.name === 'accountant'), 'subDepartments'); // OK
let b = firstOrDefault(((d: IDepartment) => d.name === 'accountant'), 'subDepartment'); // error: [ts] Argument of type '"subDepartment"' is not assignable to parameter of type '"subDepartments"'.
2
jcalz On

I don't think there's a great answer. What you are looking for is a type function that identifies the properties of a type T whose values are of type Array<T> (or Array<T> | undefined since optional properties are like that). This would be most naturally expressed by mapped conditional types, which are not yet part of TypeScript. In that case, you could make something like

type ValueOf<T> = T[keyof T];
type RestrictedKeys<T> = ValueOf<{
  [K in keyof T]: If<Matches<T[K],Array<T>|undefined>, K, never>
}>

and annotate the recursiveItem parameter as type RestrictedKeys<T> and be done. But you can't do that.


The only solution I've that actually works is to give up on extending the Array prototype. (That's bad practice anyway, isn't it?) If you are okay with a standalone function whose first parameter is an Array<T>, then you can do this:

function firstOrDefault<K extends string, T extends Partial<Record<K, T[]>>>(arr: Array<T>, pred?: (item: T) => boolean, rec?: K): T | null {
    if (!pred)
        return this.length ? this[0] : null;
    for (var i = 0; i < this.length; i++) {
        let item = this[i]
        if (pred(item))
            return item
        if (rec) {
            let subItems = item[rec]
            if (Array.isArray(subItems)) {
                var res = firstOrDefault(subItems, pred, rec)
                if (res)
                    return res
            }
        }
    }
    return null;
}

In the above, you can restrict the type T to be a Partial<Record<K,T[]>>, meaning that T[K] is an optional property of type Array<T>. By expressing this as a restriction on T, the type checker behaves as you'd like:

firstOrDefault(departments, (d:IDepartment)=>d.name=='accountant', 'subDepartments') // okay
firstOrDefault(departments, (d:IDepartment)=>d.name=='accountant', 'name') // error
firstOrDefault(departments, (d:IDepartment)=>d.name=='accountant', 'random') // error

As I said, there's no great way to take the above solution and make it work for extending the Array<T> interface, since it works by restricting T. In theory, you could express K in terms of T, like keyof (T & Partial<Record<K,T[]>>, but TypeScript does not aggressively evaluate intersections to eliminate impossible types, so this still accepts name, even though the inferred type of the name property would be something like string & IDepartment[] which shouldn't exist.

Anyway, hope the above solution can work for you. Good luck!


EDIT: I see you've solved your own problem by relaxing a different requirement: the recursiveItem parameter is no longer a key name. I still think you should consider the standalone function solution, since it works as you originally intended and doesn't pollute the prototype of Array. It's your choice, of course. Good luck again!