4

Let's say I wanted to implement a typed function chain in TypeScript, but in this case, calling a function removes that function from the return type. For example:

type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;

interface Chainable {
  execute: () => Promise<void>;
}

interface Chain1 extends Chainable {
  chain1?: () => Omit<this, 'chain1'>;
}

interface Chain2 extends Chainable {
  chain2?: () => Omit<this, 'chain2'>;
}

let chain: Chain1 & Chain2 = {
  execute: () => null,
  chain1: () => {
    delete chain.chain1;
    return chain;
  },
  chain2: () => {
    delete chain.chain2;
    return chain;
  }
};

chain.chain1().chain2().execute(); // Using the function chain

When I call chain.chain1(), I would get Pick<Chain1 & Chain2, "execute" | "chain2" as the return type, which is great, since it prevents me from calling chain1 twice.

However, as soon as I chain it with the chain2 function, the return type becomes Pick<Chain1 & Chain2, "chain1" | "execute". This would allow me to call chain1 again, which is what I'm trying to prevent. Ideally, the compiler would complain that Property 'chain1' does not exist on type:

chain.chain1().chain2().chain1(); // I want this to return a compiler error :(

Am I going about this the right way? Is it possible in TypeScript to progressively combine multiple Omit types together, so that the return types continuously omit properties?

1 Answer 1

5

I think the type for this is determined when the function is first checked and then not reevaluated at any point after. So this for the second call of chain2 will still be the original this not the return type of chain1. I am not sure if this is the intended behavior or a bug, you might want to check GitHub for similar issues.

One workaround is to capture this for any given function using a generic type parameter which will be tied to this. This will ensure correct type flow through the function chain. One small issue is that the typings will not work out using arrow function, you will need to use regular functions and access this:

type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;

interface Chainable {
    execute: () => Promise<void>;
}

interface Chain1 extends Chainable {
    chain1?: <T extends Chain1>(this: T) => Omit<T, 'chain1'>;
}

interface Chain2 extends Chainable {
    chain2?: <T extends Chain2>(this: T) => Omit<T, 'chain2'>;
}

let chain: Chain1 & Chain2 = {
    execute: () => null,
    chain1: function () {
        delete this.chain1;
        return this;
    },
    chain2: function ()  {
        delete this.chain2;
        return this;
    }
};

chain.chain1().chain2().execute(); // Using the function chain
chain.chain1().chain2().chain1().execute(); // error
Sign up to request clarification or add additional context in comments.

4 Comments

Good idea with the this parameter.
@jcalz 10x, I am now having doubts about who this is. I would have expected his code to work, I guess polymorphic this is determined when the function is declared and not re-evaluated after.
This is perfect! I fiddled with having an input parameter of this, but I didn't think of using generics to extend the class. Thank you!
This breaks down when you have user-supplied generic arguments, since the caller now has to provide the type of T for each call.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.