Variant accessors by RyanCavanaugh · Pull Request #42425 · microsoft/TypeScript (original) (raw)
Getter / Setter Variation
Overview
This implements two related features:
- The ability to have getter and setter pairs differ in their visibility modifiers (Different access modifier for getter and setter #2845, [feature] class properties that are "readonly in public, writable in private" or other permutations #37487 but not for fields)
- The ability to have getter and setter pairs differ in their types (Suggestion: allow get/set accessors to be of different types #2521)
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");