#37502•Hard

Chainable Method Guard

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 everything

Challenge Instructions: Chainable Method Guard

Hard

Implement 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).

ChallengeSolution
/* _____________ 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(

Pro Challenge

Get access to all 200+ challenges, including every medium, hard, and extreme one.

Monthly subscription.
Cancel anytime + 30-day money-back guarantee.

Detailed Explanation

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
  : Next

The chain is the accumulator

There 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.

Asking whether a resource is still there

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 // false

The 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.

Three branches, in this order

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.

Methods and getters are not the same shape

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> // Next

If 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.

Edge cases the tests cover

This challenge is originally from here.

Share this challenge

Related Challenges

Learn the Concepts

Become a TypeScript Pro

Track your progress through 200+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges