Track which resources a fluent chain has already spent inside the type system, so calling a method whose dependency is gone becomes a compile error.
A builder where every call quietly uses something up, and the type checker keeps the books.
ChainableMethodGuard<CLASS> wraps a class whose methods all return this, and rewrites each return type so that it remembers what is left. A side table, MethodDeps, says which resource each method needs. Taking a resource removes it from the set, and a method whose resource is already gone has to stop type checking. Two entries are special: a void dependency consumes nothing, and a null dependency puts the full set back. Keys that appear in CLASS but not in MethodDeps are left exactly as they were, and the wrapper has to keep the same key set as the original class.
type Resource = 'mouse' | 'face' | 'body'
type MethodDeps = {
speak: 'mouse'
reset: null
set: void
action: 'face' | 'body'
}
class G {
speak() { return this }
set() { return this }
get reset() { return this }
action() { return this }
}
declare const g: ChainableMethodGuard<G>
g.speak().action() // fine, 'mouse' and then 'face' | 'body'
g.action().action() // error, 'face' and 'body' are both spent
g.action().reset.action() // fine again, reset restores everythingImplement a type ChainableMethodGuard<CLASS> that turns a class's chainable
API into a resource-safe fluent interface.
Each method has a dependency described in MethodDeps. Calling a method that
depends on resource R will consume R from the remaining set; methods whose
dependency is not available must become type errors.
Special rules:
void dependency: does not consume anything.
null dependency: reset to the initial resource set.
Only keys existing in both CLASS and MethodDeps are guarded; other
members keep their original types.
View on GitHub: https://tsch.js.org/37502
Change the following code to make the test cases pass (no type check errors).
/* _____________ Your Code Here _____________ */
type ChainableMethodGuard<CLASS> = any
/* _____________ Test Cases _____________ */
import type { Equal, Expect } from '../helpers'
export type Resource = 'mouse' | 'face' | 'body'
export type MethodDeps = {
speak: 'mouse'
reset: null // null: reset resource set to the initial full set
set: void // void: consume nothing
expression: 'face'
motion: 'body'
action: 'face' | 'body'
all: 'mouse' | 'face' | 'body'
}
class G {
speak() { return this }
set() { return this }
get reset() { return this }
expression(Get access to all 200+ challenges, including every medium, hard, and extreme one.
Monthly subscription.
Cancel anytime + 30-day money-back guarantee.
The solution in full:
type ChainableMethodGuard<CLASS, Left extends Resource = Resource> = {
[K in keyof CLASS]: K extends keyof MethodDeps
? Guard<CLASS, Left, MethodDeps[K], CLASS[K]>
: CLASS[K]
}
type Guard<CLASS, Left extends Resource, Dep, Member> = [Dep] extends [null]
? Rewrap<Member, ChainableMethodGuard<CLASS, Resource>>
: [Dep] extends [void]
? Rewrap<Member, ChainableMethodGuard<CLASS, Left>>
: [Dep] extends [Left]
? Rewrap<Member, ChainableMethodGuard<CLASS, Exclude<Left, Dep>>>
: never
type Rewrap<Member, Next> = Member extends (...args: infer A) => unknown
? (...args: A) => Next
: NextThere is no loop here. The chain itself is the recursion, and the state it carries is Left, a second type parameter holding the resources that have not been spent yet. It defaults to the full Resource union, so ChainableMethodGuard<G> written with one argument starts from a full set, which is what the tests do.
Each property of the result is a fresh ChainableMethodGuard<CLASS, ...> with a smaller Left. That looks like an infinite type, and it would be if TypeScript evaluated it eagerly. Mapped types are lazy: the property types are only computed when something reads a property, so the chain expands exactly as far as the call expression goes and no further.
Dep is a union for some methods, such as 'face' | 'body' for action. The method is callable only when every resource it names is still in Left, which is a subset question:
// Left = 'mouse' | 'face' | 'body'
type A = ['face' | 'body'] extends [Left] ? true : false // true
// Left = 'mouse' | 'body'
type B = ['face' | 'body'] extends [Left] ? true : false // falseThe square brackets matter. Written bare as Dep extends Left, the conditional would distribute over Dep and ask the question once per member, so 'face' | 'body' against a set holding only 'body' would answer true for one half and hide the problem. Wrapping both sides in a one-element tuple keeps the union whole and turns the test into a real subset check.
Spending the resources is then one call to Exclude<Left, Dep>, which distributes over Left and drops every member assignable to Dep. When the last resource goes, Left becomes never, and every subsequent subset test fails until a reset.
Guard checks null first, then void, then the resource case. The order is not arbitrary. null and void are both inhabited by very little, and testing them in the other order is still safe under strict because null is not assignable to void, but reading the branches top to bottom as reset, free, pay keeps the intent visible. The null branch hands back Resource rather than Left, which is the whole of the reset behaviour.
The fallthrough is never. Keeping the key but typing it as never is what makes an unavailable method a type error at its call site, since never has no call signatures, while keyof on the wrapper still matches keyof on the class.
In the test class, speak is a method and reset is a getter, so the chain reads g.speak() but g.reset with no parentheses. The guard has to preserve that difference, which is what Rewrap does:
type M = Rewrap<() => unknown, Next> // () => Next
type P = Rewrap<SomeClass, Next> // NextIf the original member is callable, infer A captures its parameter list and the new type is a function taking the same arguments and returning the next state. If it is not callable, the next state is handed back directly as a property type. One infer covers both the no-argument methods in the test and any method that takes arguments.
set has a void dependency, so g.set().set() changes nothing about Left and can appear anywhere in the chain, including after the set is empty.reset is a getter, so it must stay a property. A Rewrap that wrapped everything in a function type would break .reset.speak().g.all() takes all three resources at once, leaving Left as never, so the following .action() fails even though action only needs two of them..reset.reset back to back is valid: resetting an already full set is a no-op, not an error.keyof ChainableMethodGuard<G> has to equal keyof G, which rules out dropping unavailable methods from the mapped type instead of typing them as never.This challenge is originally from here.
Track your progress through 200+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges