To achieve the goal of triggering a compiler warning in TypeScript if a method is called more than once, as well as generating a runtime error for repeated method calls, separate efforts need to be made for each objective. Unfortunately, the compiler cannot automatically enforce constraints at compile time based on runtime behavior. Therefore, we need to address these aspects individually.
Starting with the type system:
type OmitSetup<K extends string> = Omit<Setup<K>, K>;
declare class Setup<K extends string = never> {
step1(): OmitSetup<K | "step1">;
step2(): OmitSetup<K | "step2">;
step3(): OmitSetup<K | "step3">;
}
The key idea here is to utilize generics in the Setup class by defining a constrained type parameter K
representing the union of method names that should not be allowed. By default, K = never
, indicating that initially no method names are restricted.
Additionally, even if methods like step1
, step2
, and step3
are declared in Setup<K>
, they will always exist regardless of the specific value of
K</code. To handle this issue, we introduce <code>OmitSetup<K>
, which filters out these methods using the Omit utility type. As each method is called, the compiler modifies the return type by adding the new method name to the suppressed list.
At compilation time, consider the following scenario:
const s = new Setup();
// const s: Setup<never>
const s1 = s.step1();
// const s1: OmitSetup<"step1">
const s12 = s1.step2();
// const s12: OmitSetup<"step1" | "step2">
const s123 = s12.step3();
// const s123: OmitSetup<"step1" | "step2" | "step3">
This approach demonstrates how each call further restricts the available methods until eventually all methods are suppressed.
By combining types and runtime behavior, desired constraints can be enforced:
s.step1().step2().step3(); // okay
s.step2().step1().step3(); // okay
s.step1().step2().step1(); // error!
// -------------> ~~~~~
// Property 'step1' does not exist on type 'OmitSetup<"step1" | "step2">'.
// Did you mean 'step3'?
Moving on to the runtime aspect:
class Setup {
step1() {
console.log("step1");
return Object.assign(new Setup(), this, { step1: undefined });
}
step2() {
console.log("step2");
return Object.assign(new Setup(), this, { step2: undefined });
}
step3() {
console.log("step3");
return Object.assign(new Setup(), this, { step3: undefined });
}
}
In this implementation, each method returns a new object, preventing method re-calls by explicitly setting them to undefined
. Testing confirms the runtime behavior:
const s = new Setup();
s.step1().step2().step3(); // "step1", "step2", "step3"
s.step2().step1().step3(); // "step2", "step1", "step3"
s.step1().step2().step1(); // "step1", "step2", RUNTIME ERROR!
// s.step1().step2().step1 is not a function
These results demonstrate the prevention of calling the same method multiple times during execution.
Lastly, unifying both type declarations and runtime functionality in a single TypeScript file:
type OmitSetup<K extends string> = Omit<Setup<K>, K>;
class Setup<K extends string = never> {
step1(): OmitSetup<K | "step1"> {
console.log("step1");
return Object.assign(new Setup(), this, { step1: undefined }) as any
}
step2(): OmitSetup<K | "step2"> {
console.log("step2");
return Object.assign(new Setup(), this, { step2: undefined }) as any
}
step3(): OmitSetup<K | "step3"> {
console.log("step3");
return Object.assign(new Setup(), this, { step3: undefined }) as any
}
};
This version emphasizes annotating method return types and ensuring loose typing with "as any
", highlighting the independence between implementation and typings. This explicit approach aids clarity since the compiler may not infer complex relationships between data structures accurately.
Playground link to code