Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript generic type for anything but a function

Tags:

typescript

structuredClone or lodash.cloneDeep cannot 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)

TypeScript Playground

Is there any way to fix this?

like image 931
Clifford B. Wolfe Avatar asked Oct 18 '25 21:10

Clifford B. Wolfe


1 Answers

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 𝜋 by 3.14 is good enough for lots of use cases, and it's usually more trouble than it's worth to approximate it by 3.141592653589793238462643383. Let's just use the above definition.

Anyway, now let's try to use NotFunction in place of not Function:

declare function cloneAnythingButFunction<T extends NotFunction>(object: T): T;

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

class Test {
    clone1() {
        return cloneAnythingButFunction(this); // okay
    }
}

Those behave as desired. It's still possible that some subtype of Test could be assignable to Function, but the compiler doesn't care and neither do we, I think.

And of course we don't care about this:

cloneAnythingButFunction({apply: "today", call: "1-800-TYP-SCRP"}); // error oh noez

because if we did we'd have to add some more digits of 𝜋 to our approximation of NotFunction to deal with it.


Playground link to code

like image 147
jcalz Avatar answered Oct 20 '25 12:10

jcalz