TypeScript: Always use readonly arrays in generic types

April 01, 2021

Hello everyone! Today I want to share with you a small tip that will help you avoid bugs in your TypeScript code.

And, as you already read in the title, it’s about using readonly arrays in your generics types. The reason why I want to create this article is that many people (including me) often forget about this rule and sometimes it could be painful to migrate a lot of types later when your project has grown.

So imagine that you have a Tail type:

type Tail<T extends unknown[]> = T extends [unknown, ...infer Rest]
  ? Rest
  : never;

It accepts an array as a generic and returns the “tail” of it (all elements, except the first one). This would work for most cases and you may not have any bugs, but at some point, you may stumble into an issue:

type SomeArray = readonly [1, 'asdf', 2, 'fdsa'];

type SomeArrayTail = Tail<SomeArray>;
// error                  ^^^^^^^^^^
// The type 'SomeArray' is 'readonly' and cannot
// be assigned to the mutable type 'unknown[]'

This happens because in TypeScript if you mark your array as readonly, it won’t have some methods like pop, push, etc.

const arr: readonly number[] = [1, 2, 3];

arr.push(4);
//  ^^^^
// Property 'push' does not exist on type 'readonly number[]'
arr.pop();
//  ^^^
// Property 'pop' does not exist on type 'readonly number[]'

This makes sense since you should not be able to mutate your array. But this means that all your types that were meant to work with arrays, won’t work with readonly arrays. And in some situations your type could be written in a way where you may not recognize that you have a problem:

type SetA<T extends Record<string, any>> = {
  [K in keyof T | 'a']: T extends 'a' ? number : T[K];
};

// arrays
function setA<T extends unknown[]>(obj: T): T & { a: number };
// objects
function setA<T extends Record<string, any>>(obj: T): SetA<T>;
// other values
function setA(obj: any): void;
// implementation
function setA(obj: any) {
  if (typeof obj !== 'object' || obj === null) return;
  obj.a = 5;
  return obj;
}

const arr: readonly number[] = [1, 2, 3];
const arrWithA = setA(arr);

console.log(arrWithA); // [1, 2, 3, a: 5]

arrWithA.forEach(el => console.log(el)); // 1, 2, 3
const copy = arrWithA.map(v => v);

Everything would work correctly, but if you check the type of arrWithA it’ll be SetA<readonly number[]>. This means that arr was handled as an object in overload, not an array. As a result, all methods and properties were copied to the object. However, forEach and map work as expected.

But if you try to assign arrWithA to variable with type readonly number[] you’ll get an error:

const newArray: readonly number[] = arrWithA;
//    ^^^^^^^^
// Property '[Symbol.iterator]' is missing in type 'SetA<readonly number[]>'
// but required in type 'readonly number[]'

To fix it, we could update our setA function like that:

-function setA<T extends unknown[]>(obj: T): T & { a: number };
+function setA<T extends readonly unknown[]>(obj: T): T & { a: number };

And now our return type will be correct:

const arr: readonly number[] = [1, 2, 3];
const arrWithA = setA(arr); // readonly number[] & { a: number }

The only place where you should not use readonly is functions that only work with mutable arrays:

function push<A, V>(arr: A[], val: V): (A | V)[] {
  (arr as (A | V)[]).push(val);
  return arr;
}

const readonlyArr = [1, 2, 3] as const;

push(readonlyArr, 4); // error
// The type 'readonly [1, 2, 3]' is 'readonly' and cannot
// be assigned to the mutable type 'unknown[]'

Conclusion

The take away from this is that if you have arrays in your generics/conditional types, always use readonly with extends, unless your function only works with mutable arrays.

I hope you found this article useful. Share it with your friends, if you liked it.

Peace!


© 2023 Ayub Begimkulov All Rights Reserved