Skip to content
DevDepth

In-depth React internals and CSS layout guides for frontend engineers.

← Back to all articles

In-Depth Article

How to Implement `Readonly` in TypeScript: Mapped Types, `keyof`, and Why It Is Shallow

Solve the Type Challenges `Readonly` problem by learning how `readonly [P in keyof T]: T[P]` works, why it matches the built-in utility type, and why the result is shallow rather than deep.

Published: Updated: 4 min readtypescript
typescriptmapped-typesreadonlyutility-typestype-challenges

Readonly looks like a tiny variation on Pick, but it teaches a different idea that shows up all over TypeScript utility types: mapped types can preserve the same keys while changing the modifiers on each property.

If you have already worked through how to implement Pick in TypeScript, this is the next clean step. Pick chooses a subset of keys. Readonly keeps every key and changes how those properties can be written.

In this article, we will answer three practical questions:

  • What does the challenge really require?
  • What exactly does readonly [P in keyof T]: T[P] do?
  • Why is the result shallow readonly instead of deep readonly?

1. Start with what the challenge is checking

The original example already shows the whole goal:

interface Todo {
  title: string
  description: string
}

const todo: MyReadonly<Todo> = {
  title: 'Hey',
  description: 'foobar',
}

todo.title = 'Hello'
todo.description = 'barFoo'

MyReadonly<Todo> should return a new type with the same shape as Todo, but every property should be marked as readonly.

That means:

  • reading todo.title is fine
  • reading todo.description is fine
  • reassigning either property should fail in the type system

The test makes the target even stricter:

import type { Equal, Expect } from '@type-challenges/utils'

type cases = [
  Expect<Equal<MyReadonly<Todo1>, Readonly<Todo1>>>,
]

interface Todo1 {
  title: string
  description: string
  completed: boolean
  meta: {
    author: string
  }
}

So the challenge is not merely asking for "something readonly-like." It is asking for a type that behaves exactly like the built-in Readonly<T>.

That detail matters because it tells us how nested objects should behave too.

2. The complete answer is short

Here is the whole implementation:

type MyReadonly<T> = {
  readonly [P in keyof T]: T[P]
}

Like Pick, the code is compact because mapped types do most of the work for us.

There are still three parts worth understanding:

  • keyof T
  • [P in keyof T]
  • the readonly modifier before each generated property

3. keyof T gives us every property we need to transform

If T is Todo1, then:

type TodoKeys = keyof Todo1

becomes:

type TodoKeys = 'title' | 'description' | 'completed' | 'meta'

That union is the full list of keys on the original object type.

Then this part:

[P in keyof T]

means:

iterate over every key from the original type and generate a new property for each one

So if we ignore readonly for a second, the mapped type structure is simply rebuilding the same object shape:

type SameShape<T> = {
  [P in keyof T]: T[P]
}

The interesting part of Readonly is that it keeps that shape but changes the modifier on every property.

4. What the readonly modifier changes in the mapped type

Now add the modifier:

type MyReadonly<T> = {
  readonly [P in keyof T]: T[P]
}

Read it like this:

  • take each key from T
  • keep the original value type
  • mark the generated property as readonly

If we expand it for Todo1, the result is conceptually:

type MyReadonly<Todo1> = {
  readonly title: string
  readonly description: string
  readonly completed: boolean
  readonly meta: {
    author: string
  }
}

That explains why these assignments should fail:

todo.title = 'x'
todo.meta = { author: 'y' }

Both lines try to replace a top-level property that is now marked as read-only.

5. Why this challenge is about shallow readonly, not deep readonly

This is the part many readers trip over.

Readonly<T> is shallow. It does not recursively walk into nested objects and mark every inner property as readonly.

So in this example:

interface Todo1 {
  title: string
  description: string
  completed: boolean
  meta: {
    author: string
  }
}

the property meta becomes read-only, but the shape inside meta does not automatically change.

That means this reassignment should fail:

todo.meta = { author: 'new author' }

But this is a different question:

todo.meta.author = 'new author'

MyReadonly itself does not make author read-only. Whether that inner assignment is allowed depends on the type of meta.author, not on the outer Readonly wrapper alone.

This is exactly why the test compares MyReadonly<Todo1> with the built-in Readonly<Todo1>. The challenge wants the native shallow behavior, not a custom DeepReadonly.

6. Two beginner mistakes are especially common here

Mistaking Readonly for deep immutability

The mapped type only adds readonly to the top-level properties it generates. It does not recursively transform nested object types.

Mistaking type-level readonly for runtime freezing

Readonly<T> changes what TypeScript will allow at compile time. It does not call Object.freeze, and it does not make JavaScript objects magically immutable at runtime.

That distinction is worth keeping in mind:

  • readonly is a type-system rule
  • Object.freeze is a runtime behavior
  • const only protects the variable binding, not every nested property on the object

7. The reusable pattern behind the challenge

The real value of this problem is the mapped-type pattern behind it:

type NewType<T> = {
  readonly [P in keyof T]: T[P]
}

This means:

  • get all keys from the original type
  • iterate over those keys
  • keep each value type
  • add the same modifier to every generated property

That pattern is one of the foundations behind TypeScript utility types.

  • Readonly adds a property modifier
  • Partial makes every property optional
  • Pick selects a subset of keys before rebuilding the type

Once that mental model feels natural, many Type Challenges problems become much easier to read.

8. One line of code, one important mental model

The final answer is still just this:

type MyReadonly<T> = {
  readonly [P in keyof T]: T[P]
}

But behind that one line are three useful ideas:

  • keyof gets all keys from the original type
  • mapped types let you regenerate the object shape
  • readonly can be applied to every generated property in one place

And the most important nuance is the one beginners often miss:

Readonly<T> is shallow. It protects top-level properties, not every nested field recursively.

If that distinction is clear, you are already much closer to understanding how TypeScript utility types compose.

Publisher and editor

DevDepth Publisher

Independent publisher and frontend engineering writer

DevDepth is maintained as an independent frontend engineering publication focused on React internals, CSS layout systems, and production debugging guides.

React internalsCSS layout systemsfrontend debuggingrendering and performance reasoning

Each article is reviewed before publication for technical accuracy, explanation quality, metadata clarity, and internal-link fit within the current archive.

Last editorial review: Mar 23, 2026