#5181Hard

Mutable Keys

Build MutableKeys<T>, a union of the non-readonly keys of an object type. Assignability checks ignore readonly, so detecting it takes the Equal trick.

MutableKeys<T> picks all the mutable (not readonly) keys of an object type into a union. This is trickier than it sounds: readonly is invisible to extends-based assignability checks, so the usual conditional-type probes can't see it. You need the strictest comparison tool the type system offers, the Equal trick, combined with key filtering via as-remapping.

For example:

type Keys = MutableKeys<{ readonly foo: string; bar: number }>;
// expected to be 'bar'

Challenge Instructions: Mutable Keys

Hard

Implement the advanced util type MutableKeys<T>, which picks all the mutable (not readonly) keys into a union.

For example:

type Keys = MutableKeys<{ readonly foo: string; bar: number }>;
// expected to be "bar"

View on GitHub: https://tsch.js.org/5181

Change the following code to make the test cases pass (no type check errors).

ChallengeSolution
/* _____________ Your Code Here _____________ */

type MutableKeys<T> = any

/* _____________ Test Cases _____________ */
import type { Equal, Expect } from '../helpers'

type cases = [
  Expect<Equal<MutableKeys<{ a: number; readonly b: string }>, 'a'>>,
  Expect<Equal<MutableKeys<{ a: undefined; readonly b: undefined }>, 'a'>>,
  Expect<
    Equal<
      MutableKeys<{ a: undefined; readonly b?: undefined; c: string; d: null }>,
      'a' | 'c' | 'd'
    >
  >,
  Expect<Equal<MutableKeys<{}>, never>>,
]

Pro Challenge

Unlock 170+ medium, hard, and extreme challenges to master advanced TypeScript.

Monthly subscription. Cancel anytime.

Detailed Explanation

The solution needs a helper:

type IsEqual<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2
    ? true
    : false
 
type MutableKeys<T> = keyof {
  [K in keyof T as IsEqual<Pick<T, K>, Readonly<Pick<T, K>>> extends true
    ? never
    : K]: T[K]
}

The strategy: for each key, build two single-property types, one as-is and one force-readonly, then ask whether they're identical. If making the property readonly changed nothing, it was readonly already.

Why assignability can't see readonly

Your first instinct might be a conditional type, but TypeScript deliberately ignores readonly when checking assignability:

type A = { readonly x: number } extends { x: number } ? true : false // true
type B = { x: number } extends { readonly x: number } ? true : false // also true

Both directions succeed, so no extends check can distinguish a readonly property from a mutable one. You need something stronger than assignability: strict identity.

The Equal trick

IsEqual<X, Y> is the same helper the test suite uses to grade your answer:

type IsEqual<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2
    ? true
    : false

It wraps X and Y in two generic function signatures and asks whether one signature is assignable to the other. The compiler can only confirm that by proving the two conditional types T extends X ? 1 : 2 and T extends Y ? 1 : 2 would resolve the same way for every possible T, which forces it to compare X and Y for exact structural identity, modifiers included. Unlike plain extends, this check does see readonly:

[object Object]

You don't need to memorize how the internals work. The takeaway is that this idiom is the type-level ===, and it's the standard tool whenever modifiers or any need to be told apart.

Probing one key at a time

For each key K, Pick<T, K> extracts a single-property object type with its modifiers intact, and Readonly<...> produces the readonly version of it. For T = { a: number; readonly b: string }:

// K = 'a': Pick<T, 'a'> = { a: number }
//          Readonly<Pick<T, 'a'>> = { readonly a: number }
//          IsEqual → false → 'a' is mutable
// K = 'b': Pick<T, 'b'> = { readonly b: string }
//          Readonly<Pick<T, 'b'>> = { readonly b: string }
//          IsEqual → true → 'b' was already readonly

The question 'is this property readonly?' becomes 'does Readonly change anything?', a comparison the Equal trick can answer.

Filtering and collecting with as + keyof

The mapped type uses as-remapping to filter: keys where the probe says 'already readonly' are remapped to never, which removes them from the object entirely; mutable keys keep their names. Wrapping the whole thing in keyof then collects the surviving keys into a union:

// { a: number; readonly b: string }
// → mapped object: { a: number }
// → keyof: 'a'

Why Pick<T, K> inside the probe instead of comparing T[K]? Because T[K] is just the value type. The readonly modifier lives on the property, so the comparison must happen between one-property object types.

Edge cases the tests cover

The same skeleton with the branches swapped gives you ReadonlyKeys<T>, and the Equal-probe pattern generalizes to any question that plain assignability is too forgiving to answer.

This challenge is originally from here.

Share this challenge

Related Challenges

Learn the Concepts

Become a TypeScript Pro

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

Or start solving right away: explore all TypeScript challenges