TypeScript: Readonly

TypeScript: Readonly

Vamos a intentar replicar el tipo Readonly de TypeScript pero sin hacer uso del mismo.

En este desafío lo que perseguiremos es construir es la utilidad Readonly<T> que nos ofrece TypeScript pero sin utilizarla lo que implica que lo que vamos a construir es un tipo que nos retornará todos los atributos del tipo T que han sido establecidos como readonly lo que viene a indicar que estos atributos no van a poder ser reasignados (no se podrá cambiar su valor).

A modo de ejemplo:

interface Todo {
  title: string
  description: string
}

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

todo.title = 'Hello' // Error: cannot reassign a readonly property.
todo.description = 'barFoo' // Error: cannot reassign a readonly property.

Solución

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

// If we want to remove de readonly we can do
type MyReadonly<T> = {
  -readonly [K in keyof T]: T[K]
}

Explicación

Lo primero que tenemos que pensar para construir nuestra solución es que la asignación de modificador de visibilidad readonly solamente debería aplicarse sobre aquellos atributos de primer nivel del tipo de datos sobre el que trabajará nuestra utilidad. ¿Qué quiere esto decir? Pues que si ampliamos la declaración de la interfaz Todo que tenemos en el ejemplo:

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

el modificador readonly únicamente deberá aplicarse a los atributos de primer nivel (`title`, description, completed y meta) pero no a los de segundo nivel y posteriores (lo que viene a decir que el atributo author del objeto meta no tienen que tenerlo). Es decir, que lo que tenemos que obtener es un tipo de datos como el siguiente:

interface Todo {
  readonly title: string
  readonly description: string
  readonly completed: boolean
  readonly meta: {
    author: string
  }
}

Para obtener la solución lo primero que tenemos que pensar es que de alguna manera lo que se nos está pidiendo es que nuestra utilidad lo que haga es crear un nuevo objeto a partir de uno de partida por lo que siempre que nos enfrentemos a un desafío de este tipo la mejor idea de afrontarlo es pensar en una mapeo de las propiedades del objeto de partida en las propiedades del objeto con la solución.

Así pues como paso inicial establecemos que el resultado de aplicar nuestro tipo va a ser un nuevo objeto:

type MyReadonly<T> = {}

¿Y cómo podemos recorrer todas las propiedades que están recogidas en el objeto T? Pues en primer lugar tendremos que obtenerlas cosa que logramos gracias al uso del operador keyof sobre el objeto T. En el ejemplo con el que estamos trabajando tendremos algo como lo siguiente:

keyof Todo --> 'title' | 'description' | 'completed' | 'meta'

y ahora vamos a tener que recorrerlas como si estuviésemos recorriendo un array de JavaScript para lo cual nos apoyaremos en el operador in como sigue:

K in keyof Todo --> ['title', 'description', 'completed', 'meta']

Ahora que ya sabemos cómo obtener todos los atributos del objeto T la siguiente que tenemos que hacer es crear un mapped type es decir, tipo que se obtiene mapeando los datos de otro tipo. En nuestro caso lo que nosotros queremos es recorrer crear un nuevo objeto donde cada uno de sus atributos sea el mismo que el objeto T de partida por lo que escribiríamos algo como lo siguiente:

type MyReadonly<T> = {
  [K in keyof T]: ...
}

¿Y qué valor le asignaremos a cada uno de estos atributos? Pues en principio el mismo que tiene el objeto original cosa que obtenemos accediendo directamente a dicho atributo en T:

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

¿Qué hemos logrado hasta aquí? Pues la verdad es que muy poco ya que lo único que está haciendo la declaración anterior es devolvernos exactamente el mismo tipo T con el que estamos trabajando.

Nos quedará añadir el modificador readonly para poder lograr el objetivo que estamos persiguiendo para lo cual tendremos varias posibilidades. La primera de ellas consistiría en añadirlo a la parte que recoge el valor del nuevo tipo de datos:

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

pero aquí el problema radica en que TypeScript nos dará un error porque readonly es un modificador que solamente se puede aplicar sobre arrays o tuplas y nosotros no tenemos la certeza de que T[K] vaya a ser un array o tupla.

La otra posibilidad que tenemos es añadirlo antes de la declaración de cada uno de los atributos de nuestro nuevo objeto:

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

Y con esto lograríamos el resultado que estamos buscando.

Eliminar el atributo readonly

Ahora que sabemos cómo añadir el atributo readonly simplemente mencionar que TypeScript nos ofrece la posibilidad de utilizar el operador - delante de este modificar y lo que hará será quitarlo de todos aquellos atributos que lo puedan tener en el objeto T.

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

Un nivel de profundidad adicional

Podríamos irnos un nivel de profundidad en la declaración de los atributos sobre los que hay que eliminar el modificador readonly siendo este un aspecto interesante al que merece la pena dedicarle unos instantes.

El primero paso para lograrlo consistirá en utilizar un tipo condicional para determinar si el valor que tiene asignado el atributo con el que estamos trabajando es un objeto o no:

type MyReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? ... : ...
}

Y aquí es donde entra en juego el conocer que TypeScript nos va a permitir hacer recursivas a los tipos de datos que estamos definiendo de forma análoga a como se hacen llamadas a funciones recursivas en JavaScript. ¿Qué quiere esto decir? Pues que en nuestro caso en el caso de que se cumpla la condición lo que haremos será volver a aplicar MyReadonly pero en este caso pasándole el objeto que está asociado al atributo K del objeto T de partida:

type MyReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? MyReadonly<T[K]>
    : ...
}

y en el caso de que no se cumpla la condición (es decir, que el valor que tienen asignado el atributo K no sea un objeto) lo que retornaremos será dicho valor:

type MyReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? MyReadonly<T[K]> : T[K]
}