Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New --enforceReadonly compiler option to enforce read-only semantics in type relations #58296

Open
wants to merge 19 commits into
base: main
Choose a base branch
from

Conversation

ahejlsberg
Copy link
Member

@ahejlsberg ahejlsberg commented Apr 23, 2024

This PR introduces a new --enforceReadonly compiler option to enforce read-only semantics in type relations. Currently, the readonly modifier prohibits assignments to properties so marked, but it still considers a readonly property to be a match for a mutable property in subtyping and assignability type relationships. With this PR, readonly properties are required to remain readonly across type relationships:

type Box<T> = { value: T };
type ImmutableBox<T> = { readonly value: T };

const immutable: ImmutableBox<string> = { value: "hello" };
const mutable: Box<string> = immutable;  // Error with --enforceReadonly
mutable.value = "ouch!";

When compiled with --enforceReadonly the assignment of immutable to mutable becomes an error because the value property is readonly in the source type but not in the target type.

The --enforceReadonly option also validates that derived classes and interfaces don't violate inherited readonly constraints. For example, an error is reported in the example below because it isn't possible for a derived class to remove mutability from an inherited property (a getter-only declaration is effectively readonly, yet assignments are allowed when treating an instance as the base type).

interface Base {
    x: number;
}

interface Derived extends Base {  // Error with --enforceReadonly
    get x(): number;
}

In type relationships involving generic mapped types, --enforceReadonly ensures that properties of the target type are not more mutable than properties of the source type:

type Mutable<T> = { -readonly [P in keyof T]: T[P] };

function foo<T>(mt: Mutable<T>, tt: T, rt: Readonly<T>) {
    mt = tt;  // Error with --enforceReadonly
    mt = rt;  // Error with --enforceReadonly
    tt = mt;
    tt = rt;  // Error with --enforceReadonly
    rt = mt;
    rt = tt;
}

The --enforceReadonly option slightly modifies the effect of as const assertions and const type parameters to mean "as const as possible" without violating constraints. For example, the following compiles successfully with --enforceReadonly:

const obj: { a: string, b: number } = { a: "hello", b: 42 } as const;

whereas the following does not:

const c = { a: "hello", b: 42 } as const;  // { readonly a: "hello", readonly b: 42 }
const obj: { a: string, b: number } = c;  // Error

Some examples using const type parameters:

declare function f10<T>(obj: T): T;
declare function f11<const T>(obj: T): T;
declare function f12<const T extends { a: string, b: number }>(obj: T): T;
declare function f13<const T extends { a: string, readonly b: number }>(obj: T): T;
declare function f14<const T extends Record<string, unknown>>(obj: T): T;
declare function f15<const T extends Readonly<Record<string, unknown>>>(obj: T): T;

f10({ a: "hello", b: 42 });  // { a: string; b: number; }
f11({ a: "hello", b: 42 });  // { readonly a: "hello"; readonly b: 42; }
f12({ a: "hello", b: 42 });  // { a: "hello"; b: 42; }
f13({ a: "hello", b: 42 });  // { a: "hello"; readonly b: 42; }
f14({ a: "hello", b: 42 });  // { a: "hello"; b: 42; }
f15({ a: "hello", b: 42 });  // { readonly a: "hello"; readonly b: 42; }

Stricter enforcement of readonly has been debated ever since the modifier was introduced eight years ago in #6532. Our rationale for the current design is outlined here. Given the huge body of existing type definitions that were written without deeper consideration of read-only vs. read-write semantics, it would be a significant breaking change to strictly enforce readonly semantics across type relationships. For this reason, the --enforceReadonly compiler option defaults to false. However, by introducing the option now, it becomes possible to gradually update code bases to correct readonly semantics in anticipation of the option possibly becoming part of the --strict family in the future.

Fixes #13347.

@typescript-bot typescript-bot added Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug labels Apr 23, 2024
@typescript-bot
Copy link
Collaborator

Looks like you're introducing a change to the public API surface area. If this includes breaking changes, please document them on our wiki's API Breaking Changes page.

Also, please make sure @DanielRosenwasser and @RyanCavanaugh are aware of the changes, just as a heads up.

@@ -6223,6 +6231,10 @@
"category": "Message",
"code": 6718
},
"Ensure that 'readonly' properties remain read-only in type relationships.": {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a drive-by comment that as an everyday user of TS, I don't really know what a "type relationship" is. Maybe this could be worded more explicitly as:

Suggested change
"Ensure that 'readonly' properties remain read-only in type relationships.": {
"Enforce that mutable properties cannot satisfy 'readonly' requirements in assignment or subtype relationships.": {

# Conflicts:
#	src/compiler/diagnosticMessages.json
#	tests/baselines/reference/es2018IntlAPIs.types
#	tests/baselines/reference/es2020IntlAPIs.types
#	tests/baselines/reference/inferenceOptionalPropertiesToIndexSignatures.types
#	tests/baselines/reference/instantiationExpressions.types
#	tests/baselines/reference/localesObjectArgument.types
#	tests/baselines/reference/mappedTypeConstraints2.types
#	tests/baselines/reference/mappedTypeRecursiveInference.types
#	tests/baselines/reference/unionTypeInference.types
#	tests/baselines/reference/useObjectValuesAndEntries1.types
#	tests/baselines/reference/useObjectValuesAndEntries4.types
@ahejlsberg
Copy link
Member Author

@typescript-bot test it

@typescript-bot
Copy link
Collaborator

typescript-bot commented Apr 23, 2024

Starting jobs; this comment will be updated as builds start and complete.

Command Status Results
test top400 ✅ Started ✅ Results
user test this ✅ Started ✅ Results
run dt ✅ Started ✅ Results
perf test this faster ✅ Started 👀 Results

@typescript-bot
Copy link
Collaborator

Hey @ahejlsberg, the results of running the DT tests are ready.

Everything looks the same!

You can check the log here.

@typescript-bot
Copy link
Collaborator

@ahejlsberg Here are the results of running the user tests comparing main and refs/pull/58296/merge:

Everything looks good!

@typescript-bot
Copy link
Collaborator

@ahejlsberg
The results of the perf run you requested are in!

Here they are:

tsc

Comparison Report - baseline..pr
Metric baseline pr Delta Best Worst p-value
Compiler-Unions - node (v18.15.0, x64)
Memory used 192,785k (± 0.76%) 193,449k (± 0.94%) ~ 192,136k 195,896k p=0.173 n=6
Parse Time 1.35s (± 1.18%) 1.34s (± 1.67%) ~ 1.32s 1.37s p=0.796 n=6
Bind Time 0.72s (± 0.00%) 0.72s (± 0.00%) ~ 0.72s 0.72s p=1.000 n=6
Check Time 9.60s (± 0.24%) 9.58s (± 0.28%) ~ 9.55s 9.62s p=0.517 n=6
Emit Time 2.63s (± 0.64%) 2.63s (± 0.91%) ~ 2.59s 2.66s p=1.000 n=6
Total Time 14.30s (± 0.24%) 14.28s (± 0.38%) ~ 14.21s 14.33s p=0.421 n=6
angular-1 - node (v18.15.0, x64)
Memory used 1,221,857k (± 0.01%) 1,221,802k (± 0.01%) ~ 1,221,720k 1,221,930k p=0.173 n=6
Parse Time 8.30s (± 0.27%) 8.26s (± 0.25%) -0.04s (- 0.42%) 8.23s 8.29s p=0.035 n=6
Bind Time 2.23s (± 0.18%) 2.23s (± 0.38%) ~ 2.21s 2.23s p=0.115 n=6
Check Time 36.43s (± 0.20%) 36.42s (± 0.32%) ~ 36.26s 36.54s p=1.000 n=6
Emit Time 17.40s (± 1.04%) 17.37s (± 0.32%) ~ 17.28s 17.44s p=0.936 n=6
Total Time 64.35s (± 0.22%) 64.28s (± 0.17%) ~ 64.15s 64.43s p=0.470 n=6
mui-docs - node (v18.15.0, x64)
Memory used 1,753,194k (± 0.00%) 1,753,205k (± 0.00%) ~ 1,753,154k 1,753,270k p=0.873 n=6
Parse Time 10.05s (± 0.73%) 10.02s (± 0.50%) ~ 9.94s 10.07s p=0.628 n=6
Bind Time 3.35s (± 0.78%) 3.37s (± 0.69%) ~ 3.34s 3.41s p=0.196 n=6
Check Time 81.91s (± 0.27%) 82.18s (± 0.21%) +0.27s (+ 0.34%) 81.86s 82.34s p=0.045 n=6
Emit Time 0.19s (± 2.67%) 0.20s (± 2.62%) ~ 0.19s 0.20s p=0.311 n=6
Total Time 95.51s (± 0.22%) 95.77s (± 0.20%) ~ 95.43s 95.94s p=0.054 n=6
self-build-src - node (v18.15.0, x64)
Memory used 2,322,319k (± 0.04%) 2,322,364k (± 0.05%) ~ 2,321,022k 2,323,679k p=1.000 n=6
Parse Time 7.63s (± 0.85%) 7.54s (± 0.56%) -0.09s (- 1.18%) 7.50s 7.59s p=0.045 n=6
Bind Time 2.73s (± 0.78%) 2.72s (± 0.79%) ~ 2.69s 2.75s p=0.468 n=6
Check Time 49.35s (± 0.40%) 49.48s (± 0.70%) ~ 49.16s 50.13s p=0.471 n=6
Emit Time 4.02s (± 2.74%) 4.04s (± 1.59%) ~ 3.95s 4.14s p=0.471 n=6
Total Time 63.73s (± 0.41%) 63.81s (± 0.55%) ~ 63.50s 64.49s p=1.000 n=6
self-build-src-public-api - node (v18.15.0, x64)
Memory used 2,396,698k (± 0.02%) 2,396,771k (± 0.01%) ~ 2,396,518k 2,397,093k p=1.000 n=6
Parse Time 7.75s (± 0.80%) 7.78s (± 0.31%) ~ 7.74s 7.80s p=0.297 n=6
Bind Time 2.44s (± 0.62%) 2.46s (± 0.44%) ~ 2.44s 2.47s p=0.075 n=6
Check Time 50.15s (± 0.41%) 50.26s (± 0.54%) ~ 49.85s 50.66s p=0.378 n=6
Emit Time 3.96s (± 2.75%) 3.96s (± 2.51%) ~ 3.82s 4.12s p=0.689 n=6
Total Time 64.31s (± 0.44%) 64.48s (± 0.45%) ~ 63.93s 64.79s p=0.230 n=6
self-compiler - node (v18.15.0, x64)
Memory used 423,917k (± 0.01%) 423,950k (± 0.01%) ~ 423,901k 424,007k p=0.173 n=6
Parse Time 2.88s (± 1.11%) 2.89s (± 0.69%) ~ 2.86s 2.91s p=0.871 n=6
Bind Time 1.10s (± 0.89%) 1.09s (± 0.47%) -0.01s (- 1.06%) 1.08s 1.09s p=0.039 n=6
Check Time 15.38s (± 0.25%) 15.40s (± 0.46%) ~ 15.29s 15.47s p=0.470 n=6
Emit Time 1.16s (± 1.27%) 1.18s (± 1.99%) ~ 1.14s 1.20s p=0.223 n=6
Total Time 20.53s (± 0.28%) 20.55s (± 0.43%) ~ 20.42s 20.66s p=0.748 n=6
ts-pre-modules - node (v18.15.0, x64)
Memory used 369,266k (± 0.02%) 369,479k (± 0.04%) +213k (+ 0.06%) 369,312k 369,696k p=0.020 n=6
Parse Time 2.45s (± 1.11%) 2.45s (± 0.95%) ~ 2.42s 2.48s p=0.747 n=6
Bind Time 1.32s (± 1.91%) 1.32s (± 1.39%) ~ 1.30s 1.34s p=0.935 n=6
Check Time 13.31s (± 0.14%) 13.35s (± 0.26%) +0.04s (+ 0.31%) 13.29s 13.38s p=0.043 n=6
Emit Time 0.00s (± 0.00%) 0.00s (± 0.00%) ~ 0.00s 0.00s p=1.000 n=6
Total Time 17.08s (± 0.16%) 17.12s (± 0.19%) ~ 17.09s 17.17s p=0.090 n=6
vscode - node (v18.15.0, x64)
Memory used 2,923,686k (± 0.00%) 2,923,626k (± 0.00%) ~ 2,923,500k 2,923,730k p=0.378 n=6
Parse Time 11.39s (± 1.30%) 11.35s (± 0.25%) ~ 11.31s 11.39s p=0.746 n=6
Bind Time 3.54s (± 2.60%) 3.51s (± 2.50%) ~ 3.42s 3.59s p=0.516 n=6
Check Time 62.84s (± 0.44%) 62.60s (± 0.60%) ~ 62.22s 63.32s p=0.173 n=6
Emit Time 17.10s (± 7.97%) 17.66s (± 9.96%) ~ 16.49s 19.96s p=0.872 n=6
Total Time 94.87s (± 1.24%) 95.11s (± 1.60%) ~ 93.91s 97.19s p=0.810 n=6
webpack - node (v18.15.0, x64)
Memory used 409,862k (± 0.02%) 409,822k (± 0.01%) ~ 409,725k 409,901k p=0.630 n=6
Parse Time 3.95s (± 0.71%) 3.94s (± 1.10%) ~ 3.88s 3.99s p=0.418 n=6
Bind Time 1.65s (± 0.89%) 1.65s (± 1.14%) ~ 1.61s 1.66s p=1.000 n=6
Check Time 17.02s (± 0.40%) 17.02s (± 0.45%) ~ 16.94s 17.13s p=0.936 n=6
Emit Time 0.00s (± 0.00%) 0.00s (± 0.00%) ~ 0.00s 0.00s p=1.000 n=6
Total Time 22.62s (± 0.39%) 22.60s (± 0.38%) ~ 22.51s 22.73s p=0.628 n=6
xstate-main - node (v18.15.0, x64)
Memory used 459,429k (± 0.02%) 459,351k (± 0.01%) ~ 459,290k 459,404k p=0.173 n=6
Parse Time 2.69s (± 0.77%) 2.69s (± 0.80%) ~ 2.67s 2.72s p=0.685 n=6
Bind Time 0.99s (± 0.64%) 0.99s (± 0.00%) ~ 0.99s 0.99s p=1.000 n=6
Check Time 15.41s (± 0.29%) 15.40s (± 0.37%) ~ 15.32s 15.48s p=0.748 n=6
Emit Time 0.00s (± 0.00%) 0.00s (± 0.00%) ~ 0.00s 0.00s p=1.000 n=6
Total Time 19.09s (± 0.26%) 19.08s (± 0.35%) ~ 18.99s 19.19s p=0.520 n=6
System info unknown
Hosts
  • node (v18.15.0, x64)
Scenarios
  • Compiler-Unions - node (v18.15.0, x64)
  • angular-1 - node (v18.15.0, x64)
  • mui-docs - node (v18.15.0, x64)
  • self-build-src - node (v18.15.0, x64)
  • self-build-src-public-api - node (v18.15.0, x64)
  • self-compiler - node (v18.15.0, x64)
  • ts-pre-modules - node (v18.15.0, x64)
  • vscode - node (v18.15.0, x64)
  • webpack - node (v18.15.0, x64)
  • xstate-main - node (v18.15.0, x64)
Benchmark Name Iterations
Current pr 6
Baseline baseline 6

Developer Information:

Download Benchmarks

@weswigham
Copy link
Member

@typescript-bot pack this

@typescript-bot
Copy link
Collaborator

typescript-bot commented Apr 23, 2024

Starting jobs; this comment will be updated as builds start and complete.

Command Status Results
pack this ✅ Started ✅ Results

@typescript-bot
Copy link
Collaborator

typescript-bot commented Apr 23, 2024

Hey @weswigham, I've packed this into an installable tgz. You can install it for testing by referencing it in your package.json like so:

{
    "devDependencies": {
        "typescript": "https://typescript.visualstudio.com/cf7ac146-d525-443c-b23c-0d58337efebc/_apis/build/builds/161430/artifacts?artifactName=tgz&fileId=11F679CBDC619C3D19F886C85532AF465B340967E85B64682C46FBF2A9BFE36A02&fileName=/typescript-5.5.0-insiders.20240423.tgz"
    }
}

and then running npm install.


There is also a playground for this build and an npm module you can use via "typescript": "npm:@typescript-deploys/pr-build@5.5.0-pr-58296-8".;

@typescript-bot
Copy link
Collaborator

@ahejlsberg Here are the results of running the top 400 repos comparing main and refs/pull/58296/merge:

Everything looks good!

@jakebailey
Copy link
Member

Can you update tests/cases/compiler/APILibCheck.ts and enable this flag? This would let us ensure our public API will work with this flag; we already do this for exactOptionalPropertyTypes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mapped type relationships should also check this:

function test<T>(a: T) {
  let obj1: { [K in keyof T]: T[K] } = null as any;
  let obj2: { readonly [K in keyof T]: T[K] } = null as any;

  obj1 = obj2; // should error
  obj2 = obj1;
}

Currently both assignments are OK: TS playground (using this PR)

@robpalme
Copy link

nit: In the PR description the second code example does not match the preceding wording - it doesn't use readonly

@fatcerberus
Copy link

Any reason why this isn't called strictReadonly?

@ahejlsberg
Copy link
Member Author

Any reason why this isn't called strictReadonly?

Same reason as --exactOptionalPropertyTypes. It's too breaky to be in the --strict family of options and not necessarily desired by everyone.

@ahejlsberg
Copy link
Member Author

ahejlsberg commented Apr 23, 2024

nit: In the PR description the second code example does not match the preceding wording - it doesn't use readonly

Only having a get accessor implies that the property is readonly, but I suppose that is a bit subtle.

@fatcerberus
Copy link

fatcerberus commented Apr 24, 2024

It's too breaky to be in the --strict family of options and not necessarily desired by everyone.

Alright, that makes sense. That being said (and I realize this is just 🚲 🏠 at this point) I'm still not a big fan of enforceReadonly, as the implied opposite of --enforceReadonly is "don't enforce readonly", which... isn't really accurate.

@typescript-bot
Copy link
Collaborator

Thanks for the PR! It looks like you've changed the TSServer protocol in some way. Please ensure that any changes here don't break consumers of the current TSServer API. For some extra review, we'll ping @sheetalkamat, @mjbvz, @zkat, and @joj for you. Feel free to loop in other consumers/maintainers if necessary.

Comment on lines 13897 to 13898
// Return -1, 0, or 1, where -1 means optionality is stripped (i.e. -?), 0 means optionality is unchanged, and 1 means
// optionality is added (i.e. +?).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
// Return -1, 0, or 1, where -1 means optionality is stripped (i.e. -?), 0 means optionality is unchanged, and 1 means
// optionality is added (i.e. +?).
// Return -1, 0, or 1, where -1 means modifier is stripped (i.e. -?), 0 means modifier is unchanged, and 1 means
// modifier is added (i.e. +?).

@RyanCavanaugh
Copy link
Member

Tried this on a personal project (a few thousand LOC) I wrote in the 4.3 timeframe and only got one (1!!) error. Looking ahead with skipLibCheck off, there's some stuff to address in @types/node. I'll do a DT PR once the beta is out

node_modules/@types/node/globals.d.ts:85:13 - error TS2403: Subsequent variable declarations must have the same type.  Variable 'AbortSignal' must be of type '{ new (): AbortSignal; prototype: AbortSignal; abort(reason?: any): AbortSignal; any(signals: AbortSignal[]): AbortSignal; timeout(milliseconds: number): AbortSignal; }', but here has type '{ new (): AbortSignal; prototype: AbortSignal; }'.

85 declare var AbortSignal: {
               ~~~~~~~~~~~

  ../typescript/built/local/lib.dom.d.ts:2360:13
    2360 declare var AbortSignal: {
                     ~~~~~~~~~~~
    'AbortSignal' was also declared here.

node_modules/@types/node/http2.d.ts:61:22 - error TS2430: Interface 'Http2Stream' incorrectly extends interface 'Duplex'.
  Property 'destroyed' is 'readonly' in the source but not in the target.

61     export interface Http2Stream extends stream.Duplex {
                        ~~~~~~~~~~~

node_modules/@types/node/net.d.ts:75:11 - error TS2415: Class 'Socket' incorrectly extends base class 'Duplex'.
  Property 'destroyed' is 'readonly' in the source but not in the target.

75     class Socket extends stream.Duplex {
             ~~~~~~

node_modules/@types/node/stream.d.ts:480:15 - error TS2420: Class 'Writable' incorrectly implements interface 'WritableStream'.
  Property 'writable' is 'readonly' in the source but not in the target.

480         class Writable extends Stream implements NodeJS.WritableStream {
                  ~~~~~~~~

@@ -58,6 +58,22 @@ enforceReadonly1.ts(97,5): error TS2322: Type 'ImmutableValue<string>' is not as
rt = tt;
}

// A read-only property is assignable to a property declared as a method
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it might be good to add rationale behind this in the PR's description

@@ -99,9 +115,6 @@ enforceReadonly1.ts(97,5): error TS2322: Type 'ImmutableValue<string>' is not as
}

class D4 extends B4 { // Error
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this // Error comment is now outdated

@ahejlsberg
Copy link
Member Author

Latest commits exclude methods from strict checking. A bit of rationale is in order. Consider scenarios like this:

interface Base {
    foo(): void;
}

interface Item extends Base {
    name: string;
}

interface ReadonlyItem extends Base {
    readonly name: string;
}

declare let item1: ReadonlyItem;
declare let item2: Readonly<Item>;

item1 = item2;  // Error?

It seems unreasonable to error on the assignment, yet the foo property in Readonly<Item> is a readonly function type property, whereas the foo property in ReadonlyItem is a method that isn't and can't be marked readonly. Previously this wasn't an issue because we never enforced readonly semantics across type relationships.

Methods have always been considered mutable, and we don't even allow readonly modifiers on method declarations. TBH, if we did, I think most users would read it as "this method doesn't mutate the object", which wouldn't at all be the case. Interestingly, we've had very few requests for readonly methods. It doesn't really seem to be a pain point, and mutation is commonly used in interception patterns, function prototype construction, etc.

We could consider having mapped types with readonly modifiers not add readonly to properties that originate in methods, but that just doesn't seem right either. I mean, Readonly<T> is supposed to make every property readonly.

In my opinion, the best course of action is to simply exclude methods from strict readonly checking. Meaning that, in a type relationship, when a target property is declared as a method, we don't care about the readonly state of the source property.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug
Projects
PR Backlog
  
Not started
Development

Successfully merging this pull request may close these issues.

Interface with readonly property is assignable to interface with mutable property
9 participants