#5Extreme

Get Readonly Keys

Collect the readonly keys of an object type into a union. Assignability is blind to readonly, so the check has to be an identity comparison per key.

GetReadonlyKeys<T> returns a union of the keys of T that carry the readonly modifier. The type is short once you know the trick, and the trick is the interesting part: readonly is one of the few things in TypeScript that a conditional type cannot observe directly. Every extends check you might reach for answers true in both directions, so the whole challenge is about finding a comparison sharp enough to notice a modifier.

For example

interface Todo {
  readonly title: string
  readonly description: string
  completed: boolean
}
 
type Keys = GetReadonlyKeys<Todo> // expected to be 'title' | 'description'

Challenge Instructions: Get Readonly Keys

Extreme

Implement a generic GetReadonlyKeys<T> that returns a union of the readonly keys of an Object.

For example

interface Todo {
readonly title: string
readonly description: string
completed: boolean
}
 
type Keys = GetReadonlyKeys<Todo> // expected to be "title" | "description"

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

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

ChallengeSolution
/* _____________ Your Code Here _____________ */

type GetReadonlyKeys<T> = any

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

type cases = [
  Expect<Equal<'title', GetReadonlyKeys<Todo1>>>,
  Expect<Equal<'title' | 'description', GetReadonlyKeys<Todo2>>>,
]

interface Todo1 {
  readonly title: string
  description: string
  completed: boolean
}

interface Todo2 {
  readonly title: string
  readonly description: string
  completed?: boolean
}

Pro Challenge

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

Monthly subscription. Cancel anytime.

Detailed Explanation

type IsEqual<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2
    ? true
    : false
 
type Mutable<T> = { -readonly [K in keyof T]: T[K] }
 
type GetReadonlyKeys<T> = {
  [K in keyof T]-?: IsEqual<Pick<T, K>, Mutable<Pick<T, K>>> extends true
    ? never
    : K
}[keyof T]

Three pieces: a strict equality check, a helper that strips readonly, and a mapped type that puts each key through the check and then collects the survivors.

Why the obvious attempt fails

The natural first move is a conditional type. It does not work, because assignability deliberately ignores readonly:

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

Both directions succeed. There is no extends question you can phrase that separates a readonly property from a mutable one, so a stronger tool is needed.

The identity check

IsEqual<X, Y> is the Equal helper the test file uses to grade your answer, brought into the solution:

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 generic function signatures and asks whether one is assignable to the other. To decide that, the compiler has to prove the two deferred conditional types would behave identically for every T, which it can only do by comparing X and Y for exact structural identity, modifiers included. That makes it the closest thing the type system has to ===, and unlike extends it does see readonly.

Probing one key at a time

readonly lives on the property, not on the value type, so T[K] cannot help. You have to compare one-property object types. Pick<T, K> builds exactly that and keeps the modifiers intact, and Mutable returns the same object with readonly removed. If removing readonly changed nothing, there was none to remove.

For T = { readonly title: string; description: string; completed: boolean }:

// K = 'title'
// Pick<T, 'title'>          = { readonly title: string }
// Mutable<Pick<T, 'title'>> = { title: string }
// IsEqual → false → keep 'title'
 
// K = 'description'
// Pick<T, 'description'>          = { description: string }
// Mutable<Pick<T, 'description'>> = { description: string }
// IsEqual → true → discard, mapped to never

Note the polarity: IsEqual being true means the key is mutable, so the true branch yields never and the false branch yields the key name. It reads backwards at first glance.

Collecting the keys

The mapped type produces an object whose values are either the key name or never:

[object Object]

Indexing that object with [keyof T] takes the union of all its value types, and never contributes nothing to a union, so the discarded keys vanish on their own:

[object Object]

An alternative is as-remapping the keys to never and wrapping the result in keyof. Both work. The indexed-access version is worth having in your toolkit because it also lets you collect things other than key names, for instance the value types of matching properties.

The -? is not decoration

[K in keyof T]-? strips the optional modifier from the generated object. Leave it off and an optional source property produces an optional slot, whose type picks up undefined:

// without -?, on { readonly title: string; completed?: boolean }
// mapped object: { title: 'title'; completed?: never }
// indexed:       'title' | undefined

That stray undefined fails the second test case. Removing the modifier keeps every slot required and the union clean.

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 100+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges