Description
Problem
TypeScript's type system is bad at preserving type information of transformed types (such as those created with the readonly
modifier)
Examples of Current Behavior
Losing readonly
Modifiers
class TestClass {
constructor(public member: number) { }
public doNothing() {
return this;
}
}
const tester: Readonly<TestClass> = new TestClass(1);
tester.member = 2; // Error: Cannot assign to 'member' because it is a constant or a read-only property.
tester.doNothing().member = 2; // No error.
Calling Functions Which Are Ignorant of readonly
Modifiers
class TestClass {
constructor(public member: number) { }
public mutateSelf(newVal: number) {
this.member = newVal;
}
}
const tester: Readonly<TestClass> = new TestClass(1);
tester.member = 2; // Error: Cannot assign to 'member' because it is a constant or a read-only property.
tester.mutateSelf(2); // No error.
Calling functions with possibly undefined values with strictNullChecks
class TestClass {
constructor(public member: number) { }
public hopefullyExists() {
return this.member.toString();
}
}
let tester: Partial<TestClass> = new TestClass(5);
if (tester.hopefullyExists) {
tester.member.toString(); // Error: Object is possibly 'undefined'.
tester.hopefullyExists(); // No error.
}
These problems are caused because functions are context agnostic.
The first problem can be solved as a specific case in a few different ways.
// Annotate the `this` type, for specifically Readonly.
public doNothing(this: Readonly<this>);
// or make the `this` type a generic.
public doNothing<T extends this>(this: T);
These solutions are insufficient because they require
- Large work on the side of code authors to maintain.
- They are only possible in this case because of unexpected bivariance in assignability with the
readonly
property.
What Makes a Context Different
There are currently only 2 modifiers to a type in TypeScript
readonly
which marks a property as read-only, but does not change assignability.?
which marks the existence of the property as optional.
JavaScript also allows any function to be called with any context with the Function#call
, Function#bind
, and Function#apply
commands. There is no current enforcement in the type system that these are valid calls.
Core Proposal
To solve these issues, a function must be able to give more control over its context; and for this to be viable in large projects, the allowable context must be determinable by the type checker.
The Implementation Solution
When an implementation of a function is known, determining the context's from which it can be called should be fairly trivial for the type checker. A brief overview of what that would look like:
- If the
this
value is never used, then the context can beany
. - For every use of
this
, determine if the member is accessed, add it to a set of read members.
2.1. If it was read in a way that would be legal if it were an optional member, then add it to the optional set.
2.2. Determine if the member is written to, add it to set of written members. - Then, emit the function type declaration as
aFunction<this extends
{+readonly [R in read]: this[R]} &
{-readonly [W in written]: this[W]} &
{[O in optional]+?: this[O]}
>();
The Declaration Solution
Declaration files not generated by the type checker would be able to (and already can) add their own explicit context to a function. While the type checker has the opportunity to be very detailed in the required context, a hand-curated declarations file would be best served by using more broad strokes, such as:
aFunction<this extends Partial<this>>();
this
In Generics
Currently the this
type cannot be used in theConstraint
portion of a generic TypeParameter
. This would need to not be the case for this implementation to work in the wild.
When the keyword this
appears as the BindingIdentifier
in a TypeParameter
it is shorthand for the following:
aFunction<this extends T>();
// Is equivalent to:
aFunction<CTX extends T>(this: CTX);
The reason for not just using the equivalent is because the this extends T
allows the type checker to also assert that this
extends T
, which is confusing only because of the heavy use of this
. A few examples:
interface RandomInterface {
randomMember: number;
}
class ClassA {
public member: number;
badFunc<this extends RandomInterface>(); //Error: ' ClassA' does not extend '{randomObject: number}'
goodFunc<this extends Partial<RandomInterface>>(this: RandomInterface); // Succeeds: 'RandomInterface' extends 'Partial<RandomInterface>'
normalFunc<this extends Partial<this>>(); // Succeeds: 'ClassA' extends 'Partial<ClassA>'
}
In that example goodFunc
is using the already existing method of marking an explicit this
type for a function.
The this extends Partial<RandomInterface>
could have been written as this extends Partial<this>
for the same meaning.
Compiler Flag
Because this change could cause existing code to not compile, it should be added behind a compiler flag strictContextChecks
, this would affect if the declarations files that are output from implementations would include this context, also if the compiler was adding them implicitly for type checking a single project.
JavaScript Emit
None of the proposed changes would have any effect on the JavaScript emit.
Additional Comments
Until readonly a: number
is not assignable to a: number
then most issues with readonly are not solvable in a satisfying way.