Variant accessors by RyanCavanaugh · Pull Request #42425 · microsoft/TypeScript (original) (raw)

Getter / Setter Variation

Implements #2845 and #2521

Overview

This implements two related features:

Differing Types

Property getters and setters may now differ in their types:

const thing = { _size: 0, get size(): number { return this._size; }, set size(value: string | number) { this._size = typeof value === 'string' ? parseInt(value) : value; } }

// OK thing.size = "100ish"; // OK console.log(thing.size.toFixed(2));

Restrictions

As a conservative implementation, a few restrictions are in place.

First, the type of the getter must be assignable to the type of the setter. In other words, the assignment

must be legal assuming obj.x is writable at all.

This restriction closes off a certain set of use cases for properties that start out "uninitialized" (usually null/undefined) but then only want "initalizing" assignments to occur. We'll continue to examine these to see if those use cases are prevelant enough to warrant opening this up more.

These prevent novel unsoundness from occurring and makes this feature unimpactful from a type relational perspective, which limits its risk.

Differing Visibility

In classes, set accessors may now be less visible than their corresponding get accessor. It looks like this:

class MyClass { get size(): number { // TODO: implement return 0; } protected set size(value: number) { // TODO: implement }

grow() {
    // OK
    this.size++;
}

}

let c = new MyClass(); // Error, can't write to 'size' outside of 'MyClass' c.size = 10; // OK console.log(c.size);

Type Relationship Effects

TL;DR: there are none

TypeScript is already covariant when relating properties of types. The unsoundness of this is straightforward to demonstrate during aliased writes:

type Alpha = { x: string | number }; type Beta = { x: string };

function mutate(ref: Alpha) { ref.x = 0; } const p: Beta = { x: "hello" }; mutate(p); // Prints 'number' on a binding typed as 'string', oops. console.log(typeof p.x);

TypeScript also already ignores the readonly modifier when relating types, so the protected or private state of a setter does follows the same pattern.

Caveats

TL;DR: The type system remains entirely covariant in other operations! This has some effects that may not be immediately apparent.

Types with variant getters/setters are effectively reduced to their get side when put through mapped types:

// Setter types are intentionally not preserved // through mapped types or other transforms; they // are specific to their originating declaration declare const CoercingThing: { get size(): number; set size(v: number | string); } type PCT = Partial; declare const pct: PCT; // Not OK pct.size = "hello";

This is fairly straightforward to reason about -- a mapped type usually represents a "copy with transform" operation, e.g.

(psuedocode: some type operation f applied to an object type obj)
result = { }
for k in keyof obj
  result[k] = f(obj[k]);
return result

In other words, for an arbitrary mapped type, we have no idea how its actual value is produced, and the only sound assumption is that this type doesn't copy over any coercing semantics from its setters.

The same applies to lookup types -- the type T[K] still means *the read type of K on T. A free setter function can't be used to indirectly access the coercing side of the setter:

declare const CoercingThing: { get size(): number; set size(v: number | string); } function setter<T, K extends keyof T>(obj: T, key: K, value: T[K]) { obj[key] = value; } // Not OK, even though CoercingThing.size = "100" is OK setter(CoercingThing, "size", "100");