There's some nuance and complication around what readonly
means in TypeScript. Objects with readonly
properties are considered mutually assignable with objects of the same shape whose properties are mutable. So nothing you do in TypeScript will either require or prohibit objects with or without readonly
properties from being passed as arguments:
const xr: { readonly a: string } = { a: "" };
const xm: { a: string } = { a: "" };
let r = xr; r = xm; // okay
let m = xm; m = xr; // okay
function foo(x: { readonly a: string }) { }
foo(xr); // okay
foo(xm); // still okay
function bar(x: { a: string }) { }
bar(xm); // okay
bar(xr); // still okay
There is a longstanding request at microsoft/TypeScript#13347 to change this behavior in some way, but until and unless that gets implemented, this is the way it is.
Of course you're not just talking about some objects with readonly
properties in your example code; you're specifically talking about readonly
arrays, a.k.a., ReadonlyArray<T>
or readonly T[]
. These are strictly supertypes of mutable Array<T>
or T[]
types in TypeScript. In addition to having readonly
elements, a readonly
array is not known to have the mutating array methods like push()
or shift()
. So you can assign a mutable array to a readonly
one, but not vice versa:
let rArr: readonly string[] = ["a"];
let mArr: string[] = ["a"];
rArr = mArr; // okay
mArr = rArr; // error
This tends to confuse people; perhaps instead of ReadonlyArray
and Array
, the names "should" be ReadableArray
and ReadableWritableArray
... but they couldn't really do that because Array
already exists as JavaScript and TypeScript needs to conform to that naming. So the naming is what it is.
It also worth noting that in practice, all ReadonlyArray<T>
types are just regular read-write Array<T>
objects at runtime. It's not like there's a ReadonlyArray
constructor whose instances lack push()
and shift()
. So even though the compiler tries to prevent you from assigning a readonly
array to a mutable array, it's not likely to cause a runtime error if you manage to do it.
Armed with that, let's look at the question as asked. In order to be safe, you should only be allowed to override methods with parameters that are wider than the parent method's parameters. That is, function types should be contravariant in their parameters (see Difference between Variance, Covariance, Contravariance and Bivariance in TypeScript ). But TypeScript doesn't enforce this rule for methods. Instead, methods are considered to be bivariant in their parameters; in addition to letting you widen the parameters (safely), TypeScript also lets you narrow the parameters (unsafely). There are reasons for that, but it means TypeScript will happily let you do some unsafe things, as shown:
class X {
method1(x: string) { }
method2(x: string) { }
}
class Y extends X {
method1(x: string | number) { // safe
console.log(typeof x === "string" ? x.toUpperCase() : x.toFixed(1))
}
method2(x: "a" | "b") { // unsafe
console.log({ a: "hello", b: "goodbye" }[x].toUpperCase())
}
}
const x: X = new Y();
x.method1("abc"); // "ABC"
x.method2("abc"); // RUNTIME ERROR!
So that's why World
is allowed to accept a mutable string[]
in its hello()
method. The subclass "should" complain that for World
to be a valid Base
it cannot safely assume that the arguments to hello()
will be mutable arrays. It doesn't, because of bivariance. But of course, as mentioned above, in practice there will be no runtime error because of this, since "readonly
" arrays are just arrays.
Anyway, originally all function parameters in TypeScript were checked bivariantly. But now if you turn on the --strict
suite of compiler options or specifically just the --strictFunctionTypes
compiler option, then non-method function parameters will be checked contravariantly. Methods are still bivariant though.
So one possible approach you can take to change the behavior is to replace your method with a function-valued property. This has other noticeable effects, since methods are defined on the class prototype
and not the instance, so it might not be appropriate. But it enforces the restriction you were expecting:
abstract class Base {
abstract hello(ids: readonly string[]): void;
abstract goodbye: (ids: readonly string[]) => void;
}
class World extends Base {
hello(ids: string[]): void { // okay
ids.push('hello world')
console.log(ids);
}
goodbye = (ids: string[]) => { // error as desired
ids.push('hello world')
console.log(ids);
}
}
Playground link to code