Home > Software design >  Typescript generic type for anything but a function
Typescript generic type for anything but a function

Time:10-04

structuredClone or lodash.cloneDeep can not clone functions.

Is there a way to exclude Function type from generic?

I tried object: Exclude<T, {[key: string|number|symbol]: any, apply: any, call: any}> and object: Exclude<T, Function>, both will return a type error when the object is passed in as this of a class.

function cloneAnythingButFunction1<T>(object: Exclude<T, {[key: string|number|symbol]: any, apply: any, call: any}>): T{
    return structuredClone(object)}

function cloneAnythingButFunction2<T>(object: Exclude<T, Function>): T{
    return structuredClone(object)
}

// Expect error:
cloneAnythingButFunction1(cloneAnythingButFunction1)
cloneAnythingButFunction2(cloneAnythingButFunction2)

// Expect no error:
class Test{
    clone1(){
        return cloneAnythingButFunction1(this) // error
    }
    clone2(){
        return cloneAnythingButFunction2(this) // error
    }
}
const test = new Test()
cloneAnythingButFunction1(test)
cloneAnythingButFunction2(test)

https://www.typescriptlang.org/play?ts=4.9.0-dev.20221003&ssl=15&ssc=2&pln=1&pc=1#code/MYGwhgzhAEAqCmEAuBvAUNT1QHsB28AjABQCU6Wl0ATvEgK7V7Yj7wCCeAnkgBYCWeAOYAhekgBi9PMCT98JPvwikMWAL5rMuAgCYyFKploMmLNpx4DhYydNny8 pSq3RNmtADN7c-OYJLJRtxKRk-PEIAHlgAPmIcACMAK3hZAC5oAFEAD1B6ABN4GIAaaBQAbQBreC5M5GpBIQAfPHoAW0T4amaILk6cEABdTLBuMrAAB0mQOugxrjLgMBAQUe51WNJM2ENjOkZmBvpZRngCgGFWAgSUtKRSTx9wxwCObmDRUN9HXRj4pKpDLZPIgQrFWBlMIOfBbHZ7GgHMzHU60S7XeC3IEPNDqIA

Is there any way to fix this?

Thanks

CodePudding user response:

If TypeScript had negated types of the sort implemented in (but never merged from) microsoft/TypeScript#29317 then you could just write

// Don't do this, it isn't valid TypeScript:
declare function cloneAnythingButFunction<T extends not Function>(object: T): T;

and be done with it. But there is no not in TypeScript, at least as of TypeScript 4.8, so you can't.


There are various ways to try to simulate/emulate not. One way is to do what you're doing: write a conditional type that acts like a circular generic constraint, which you've done here:

declare function cloneAnythingButFunction<T>(object: Exclude<T, Function>): T;

This works well for parameters of specific types, like

cloneAnythingButFunction(123); // okay
cloneAnythingButFunction({ a: 1, b: 2 }); // okay
cloneAnythingButFunction(Test) // error
cloneAnythingButFunction(() => 3) // error
cloneAnythingButFunction(new Test()); // okay    

but when the parameter is itself of a generic type, then it can break down. And the polymorphic this type of this inside a class method is an implicit generic type parameter. (That is, it is treated like some unknown type constrained to the class instance type). The compiler doesn't know how to verify assignability of this to Exclude<this, Function>, which makes sense because the compiler does not know how to say that some subtype of Test might not also implement Function.

You can work around it by widening this to a specific supertype, like Test:

class Test {
    clone1() {
        const thiz: Test = this;
        return cloneAnythingButFunction(thiz); // okay
        // return type is Test, not this
    }
}

Another approach to approximating negated types is to carve up the set of all possible types into pieces that mostly cover the complement of the thing you're trying to negate. We know that no primitive types are functions, so we can start with

type NotFunction = string | number | boolean | null | undefined | bigint | ....

And then we can start adding in object types that are also not functions. Probably any arraylike type will not also be a function:

type NotFunction = string | number | boolean | null | undefined | bigint | 
   readonly any[] | ...

And any object that doesn't have a defined apply property is also not a function:

type NotFunction = string | number | boolean | null | undefined | bigint | 
   readonly any[] | { apply?: never, [k: string]: any } | ...

And any object that doesn't have a defined call property is also not a function:

type NotFunction = string | number | boolean | null | undefined | bigint | 
   readonly any[] | { apply?: never, [k: string]: any } | 
   { call?: never, [k: string]: any };

Should we keep going or stop there? The above definition of NotFunction will misclassify any objects with both an apply property and a call property. Are we worried about those? Are we likely to run into non-function objects with properties named apply and call? If so, we can add more pieces. Maybe we want to add objects without a bind property. Or objects with bind, call, and apply properties but where each of those properties are themselves primitives, like { call: string | number | ... , apply: string | number | ... }... But at some point we should just stop. Approximating

  • Related