1

See this example:

abstract class Base {
    abstract hello(
        ids: readonly string[],
    ): void;
}

class World extends Base {
    hello(ids: string[]): void {
        ids.push('hello world')
        console.log(ids);
    }
}

const ids: readonly string[] = ['id'];
const t:Base = new World();
t.hello(ids)

When I implement the base class why am I not forced to make the prop readonly?

Playground

Magnus O.
  • 468
  • 5
  • 17
  • 1
    Anything that can accept a readonly array can also accept a mutable array - method parameter types are contravariant. – jonrsharpe Jun 01 '23 at 08:01
  • Yes, we use dependency injection (nestjs) and the classes import the Baseclass but will get the implementation from the parent class. So if the parent class (World) then modifies the list but the users of the class sends in a readonly array it will be strange right? – Magnus O. Jun 01 '23 at 10:17
  • I updated the example to make the params `ids` readonly. As a user of and instance of the `Base` class I would expect the list to change... – Magnus O. Jun 01 '23 at 10:19
  • @jonrsharpe but this is a case of *covariant* method params, since the subclass makes assumptions not known to be true from the superclass. It's method bivariance that allows this: See [this playground link](https://tsplay.dev/wXr9Jm) – jcalz Jun 01 '23 at 15:13
  • The answer to the question as asked is that [method parameters a bivariant](https://www.typescriptlang.org/docs/handbook/type-compatibility.html#function-parameter-bivariance). If you switch to function valued properties instead of methods and use `--strictFunctionTypes`, you'll get the error you expected, as shown [here](https://tsplay.dev/wXr9Jm). Does that fully address the question? If so then I'll write up an answer (or find an acceptable dupe target). If not, what am I missing? – jcalz Jun 01 '23 at 15:15
  • @jcalz very nice. I understand the issue now and this solves the issue. Thank you. – Magnus O. Jun 02 '23 at 09:42

1 Answers1

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360