Skip to content

Typed Function for better class inheritance.Β #60596

Closed

Description

πŸ” Search Terms

"Typed Function", "typed Callable object", "extends Function", "Function type definitions", "Function typed Parameters", "Function typed ReturnType", "generic Function types"

βœ… Viability Checklist

⭐ Suggestion

I'd like to be able to define the actual Function type with some generic types for its parameters and return type.

In my head, something like:

type Function<P extends any[] = any[], R = any>;

... where P is the type of the parameters and R is the return type.

Or -yet- better to support optional parameters:

type Function<T extends (...args: any[]) => any = (...args: any[]) => any>;

... where T is the actual function type on which internals can perform Parameters<T> and ReturnType<T>.

This would allow developers to define types like this:

type MyFunction = Function<(value: number, base?: number) => boolean>;

πŸ“ƒ Motivating Example

I do know well that -as described- it seems to be completely useless.
This hypothetical type MyFunction won't be different from:

type MyFunction = (value: number, base?: number) => boolean;

So why this proposal?


The real reason to use this comes from the need to implement something like this:

class MyFunction extends Function<(value: number, base?: number) => boolean>
{
  /* ... */
}

By extending the Function class, you can make objects callable just like any other function, while still having their own properties and methods.


Right now, it is possible to make an object callable like so, while isn't allowed to strictly define what kind of parameters it takes or what kind of result it will return.
The current type will be (...args: any[]) => any which "disables" all TypeScript typing and doesn't allow relying on types when using such objects.

πŸ’» Use Cases

1. What do you want to use this for?

I'm going to use this like -as I said- to strictly type callable objects in a secure way.

2. What shortcomings exist with current approaches?

The type system isn't reliable when these kind of objects are involved.

3. What workarounds are you using in the meantime?

I'm not implementing a callable object, since I need to rely on type system.
I'm using some wrappers, which make things more complex to handle and less readable, while adding a little JavaScript overhead in a case where it's just a typing issue and shouldn't involve any additional JavaScript code at all.

Activity

  1. jcalz commented on Nov 25, 2024

    @jcalz
    Contributor

    What's in the /* ... */ there? How does the function body passed as a string ensure that it actually implements (value: number, base?: number) => boolean? Seems like nothing stops new MyFunction() which will always return undefined when called. I'm having a hard time imagining how TS would actually provide the type safety implied by this.

  2. Byloth commented on Nov 26, 2024

    @Byloth
    Author

    Oh, sorry... My bad!
    Of course, my /* ... */ is a comment to leave space to whatever implementation a developer would need.

    I would never imagine that the body of a generic class -in this case- would affect in some way what I was saying...
    But... Anyway... I'm going to provide you what I was trying to implement:

    abstract class CallableObject<T extends (...args: any[]) => any = () => void> extends Function {
        public constructor() {
            super(`return this.invoke(...arguments);`);
    
            return this.bind(this);
        }
    
        public abstract invoke(...args: Parameters<T>): ReturnType<T>;
    }
    
    class SwitchableCallback<T extends (...args: any[]) => any = () => void> extends CallableObject<T> {
        protected _key: string;
        public get key(): string { return this._key; }
    
        protected _callback: T;
        protected _callbacks: Map<string, T>;
    
        public constructor()
        {
            const _default = () => {
                throw new Error(
                    "Unable to invoke a `SwitchableCallback` without any callback defined. " +
                    "Did you forget to call the `register` method?"
                );
            };
    
            super();
    
            this._key = "";
    
            this._callback = ((_default) as unknown) as T;
            this._callbacks = new Map<string, T>();
        }
    
        public invoke(...args: Parameters<T>): ReturnType<T> {
            return this._callback(...args);
        }
    
        public register(key: string, callback: T): void {
            if (this._callbacks.size === 0) {
                this._key = key;
                this._callback = callback;
            } else if (this._callbacks.has(key)) {
                throw new Error(`Unable to register a callback with key '${key}' as it already exists.`);
            }
    
            this._callbacks.set(key, callback);
        }
        public unregister(key: string): void {
            if (!(this._callbacks.has(key))) {
                throw new Error(`Unable to unregister a callback with key '${key}' as it does not exist.`);
            }
    
            this._callbacks.delete(key);
        }
    
        public switch(key: string): void  {
            if (!(this._callbacks.has(key))) {
                throw new Error(`Unable to switch to the callback with key '${key}' as it does not exist.`);
            }
    
            this._key = key;
            this._callback = this._callbacks.get(key)!;
        }
    }
    
    const onPointerMove = new SwitchableCallback<(event: PointerEvent) => void>();
    
    onPointerMove.register("pointerup", (evt: PointerEvent) => {
        /* Do whatever you need to do while the mouse is moving and no buttons are pressed... */
    });
    onPointerMove.register("pointerdown", (evt: PointerEvent) => {
        /* Do whatever you need to do while the mouse is moving and whatever button is pressed... */
    });
    
    window.addEventListener("pointerup", () => onPointerMove.switch("pointerup"));
    window.addEventListener("pointerdown", () => onPointerMove.switch("pointerdown"));
    
    // First kind use:
    window.addEventListener("pointermove", onPointerMove); // TypeScript doesn't recognize this as a valid EventListener. Odd but fair enough.
    window.addEventListener("pointermove", () => onPointerMove()); // For TypeScript that's ok.
    window.addEventListener("pointermove", (evt) => onPointerMove(evt)); // For TypeScript that's ok. Of course.
    window.addEventListener("pointermove", () => onPointerMove(Math.PI, "Lorem ipsum dolor sit amet.", { width: 1920, height: 1080 })); // For TypeScript that's ok too.
    
    // Second kind use:
    window.addEventListener("pointermove", onPointerMove.invoke); // For TypeScript that's ok.
    window.addEventListener("pointermove", () => onPointerMove.invoke()); // For TypeScript that's not ok. Obviously.
    window.addEventListener("pointermove", (evt) => onPointerMove.invoke(evt)); // For TypeScript that's ok too.
    window.addEventListener("pointermove", () => onPointerMove.invoke(Math.PI, "Lorem ipsum dolor sit amet.", { width: 1920, height: 1080 })); // For TypeScript that's not ok.

    At the end of the day, I perfectly know that if the first kind of use isn't type-safe, I can fall back on the second kind.
    They do the same exact thing.

    What I was pointing out is that this is a missing type-check in TypeScript.
    Probably I'm the only one that ever dared to extend a Function class to implement a callable object... Sad but makes sense. πŸ˜…

    But if it were possible to define the type of the actual Function using generics, this kind of problem would be solved.
    The defined generic type on Function -of course- would be the actual function signature to let TypeScript know what kind of function it is, what kind and how many parameters it accepts and what value type it returns.

    abstract class CallableObject<T extends (...args: any[]) => any = () => void> extends Function<T> {
    
        /* ...

    At the moment, I'm using some functions to workaround this problem:

    function createSwitchableCallback<T extends (...args: any[]) => any = () => void>()
    {
        return new SwitchableCallback() as SwitchableCallback<T> & T;
    }

    I also tried to define a decorator to do this and to make @callable any class...
    But a decorator does not change the TypeScript type of the class so I wasn't able to solve this problem.

  3. mkantor commented on Nov 26, 2024

    @mkantor
    Contributor

    You may be aware of this, but TypeScript already has the ability to model well-typed functions with additional properties. Here's a silly example:

    function foo(value: string): number {
      foo.aggregateValue += value
      return foo.aggregateValue.length
    }
    foo.aggregateValue = ''

    The type of such a function can be described using an interface with a call signature:

    interface Foo {
      (value: string): number
      aggregateValue: string
    }

    The handbook has more info.

  4. Byloth commented on Nov 26, 2024

    @Byloth
    Author

    Hi, Matt Kantor (@mkantor)!

    Yeah... I tried it in one of my tests...
    Sadly, I can't inherit it to a new class and extend the foo function.

    There's no inheritance in this case.


    I also tried to define my class as follows:

    interface IFunction<T extends (...args: any[]) => any = () => void> {
        (...args: Parameters<T>): ReturnType<T>;
    }
    
    abstract class CallableObject<T extends (...args: any[]) => any = () => void> extends Function implements IFunction<T> {
    
        /* ...

    ... but -again- it ends up with an typing error since CallableObject doesn't implement the IFunction signature... Apparently.


    During one of my attempts, I also tried something as follows:

    abstract class _CallableObject<T extends (...args: any[]) => any = () => void> extends Function { /* ... */ }
    export type CallableObject<T extends (...args: any[]) => any = () => void> = _CallableObject<T> & T;
    
    class SwitchableCallback<T extends (...args: any[]) => any = () => void> extends CallableObject<T> {
    
        /* ...

    ... but then I wasn't able to inherit it in a child class, since you cannot inherit a type.

  5. mkantor commented on Nov 26, 2024

    @mkantor
    Contributor

    I wouldn't use classes at all for what you're trying to do. Classes aren't designed to produce callable instancesβ€”even in plain JavaScript there's no way to make this work without manual prototype hacking or unsafely creating a Function from a string like you're doing, both of which should put up red flags.

    Here's a simpler and safer way to handle your use case:

    let onPointerMove = (event: PointerEvent) => {}
    
    const pointerup = (evt: PointerEvent) => {
        /* Do whatever you need to do while the mouse is moving and no buttons are pressed... */
    };
    const pointerdown = (evt: PointerEvent) => {
        /* Do whatever you need to do while the mouse is moving and whatever button is pressed... */
    };
    
    window.addEventListener("pointerup", () => { onPointerMove = pointerup });
    window.addEventListener("pointerdown", () => { onPointerMove = pointerdown });
    
    window.addEventListener("pointermove", onPointerMove); // For TypeScript that's ok.
    window.addEventListener("pointermove", () => onPointerMove()); // For TypeScript that's not ok. Obviously.
    window.addEventListener("pointermove", (evt) => onPointerMove(evt)); // For TypeScript that's ok too.
    window.addEventListener("pointermove", () => onPointerMove(Math.PI, "Lorem ipsum dolor sit amet.", { width: 1920, height: 1080 })); // For TypeScript that's not ok.

    If you're convinced that you really do need to make a class with callable instances, I think this approach is better than extending Function (but again I strongly advise against going down this path in the first place).

  6. jcalz commented on Nov 26, 2024

    @jcalz
    Contributor

    I don't see a benefit in modifying Function for everyone who uses TS, especially since the motivating use case looks like an exceptionally uncommon approach to implementing functions. Can't you just assign Function to a new variable and give it whatever construct signature you want? Maybe like

    const MyFunction = Function as any as new
        <F extends (...args: any) => any>(...args: string[]) => (...args: Parameters<F>) => ReturnType<F>;
    
    abstract class CallableObject<T extends (...args: any[]) => any = () => void> extends MyFunction<T> {
        public constructor() {
            super(`return this.invoke(...arguments);`);
            return this.bind(this) as CallableObject<T>; // <-- need this assertion
        }
    
        public abstract invoke(...args: Parameters<T>): ReturnType<T>;
    }
    
    class Foo extends CallableObject<(x: string) => number> {
        invoke(x: string) {
            return x.length
        }
    }
    
    const f = new Foo();
    console.log(f("abc")); // 3

    Playground link to code

    I'm not saying the particulars of the above are what you want, just that nothing is stopping you from doing this locally in your own code. Is there something where the actual language needs to change in order to support this use case? The only thing I can think of is if you wanted TS to somehow verify that the string-valued constructor arguments actually satisfies the call signature, but that is almost certainly too complex. Maybe I'm just not seeing something obvious here?

  7. Byloth commented on Nov 26, 2024

    @Byloth
    Author

    The approach of Matt Kantor (@mkantor), as obvious as it may be, it doesn't work as intended.
    The reference of onPointerMove defined like so, would change without let other handlers knowing about it.

    let onPointerMove = (event: PointerEvent) => console.log("#0");
    
    const pointerup = (evt: PointerEvent) => console.log("#1");
    const pointerdown = (evt: PointerEvent) => console.log("#2");
    
    window.addEventListener("pointerup", () => { onPointerMove = pointerup });
    window.addEventListener("pointerdown", () => { onPointerMove = pointerdown });
    
    window.addEventListener("pointermove", onPointerMove);

    Using this approach -from now on- will always print #0 without any possibility of change.
    Using () => onPointerMove() would solve this specific problem but -again- would also create other problems...


    Anyway...

    The solution proposed from Joe Calzaretta (@jcalz) it actually work. Perfectly, indeed!
    That's exactly what I was trying to do. Nothing less. Nothing more.

    Thanks. ❀️


    In conclusion, IMHO...

    You actually do recognize what the use case is...
    You actually do recognize that this is something that TypeScript doesn't handle properly...
    But since this is an exceptionally uncommon approach I can handle it in my own code rather than make it available to everyone.

    Have you ever thought about whether arrays were an extremely uncommon approach?
    const MyArray = Array as any as new<T>(...args: T[]) => T[];

    Fair enough... The important thing is that it's at least implementable, somehow...

    That's enough for me.
    Thank you for your support.

    Hoping this could be useful to someone else, one day.

  8. locked as resolved and limited conversation to collaborators on Oct 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions