Skip to content

Add a mutating return type operator to indicate whether a function modifies a property indirectly #29346

Description

Search Terms

  • mutating function

Suggestion

I would like to see a return type operator T mutates this[A], U[B], ... to indicate that zero or more properties on types, like this or U above, are modified when the method is called. If either this[A] or U[B] is readonly, the method should be visible but uncallable, and if all depended-upon properties are writable, it should be both visible and callable. From the callee's perspective, it'd only see T.

Alternatively, you could implement it as a helper type Mutates<T, [{O: this, K: A}, {O: U, K: B}]>, but there are pitfalls I will explain here in a bit:

typeMutates<T,Depsextends{O: unknown,K: keyof this["O"]}[]>={0: Depsextends[] ? T :
((d: Deps)=>void)extends((first: infer A, ...rest: infer B)=>void) ?
{readonly[PinA["K"]]: unknown}extendsA["O"] ? Mutates<T,B> :
never :
never}[0]

The implementation above is complicated (I need the ability to intersect tuples' entries like I can union their entries via T[number]), but it's conceptually simple: foo(value: A): Mutates<B, this[number]> is equivalent to foo(value: A): this extends {readonly [P in number]: unknown} ? never : B.

The issue with this deriviation is that it doesn't prevent you from calling the method - it just prevents you from using the result. So if you were to add a theoretical uncallable primitive type to prevent even invoking the function, it'd work more like this:

typeMutates<T,Depsextends{O: unknown,K: keyof this["O"]}[]>={0: Depsextends[] ? T :
((d: Deps)=>void)extends((first: infer A, ...rest: infer B)=>void) ?
{readonly[PinA["K"]]: unknown}extendsA["O"] ? Mutates<T,B> :
uncallable :
never}[0]

This would change the desugaring to this, which is what I really want: foo(value: A): Mutates<B, this[number]> is equivalent to foo(value: A): this extends {readonly [P in number]: unknown} ? uncallable : B. However, it's pretty plainly obvious that this is a terrible idea to implement as a primitive type, which is why I proposed it as a new return type operator.

In terms of assignability, (value: A) => T mutating U[K] is assignable to (value: A) => T and (value: A) => T mutating SubtypeOfU[K], but not (value: Readonly<A>) => T, (value: Readonly<A>) => never, or (value: A) => T mutating SupertypeOfU[K]. Also, (value: A) => T and (value: Readonly<A>) => T are themselves assignable to (value: Readonly<A>) => T mutating U[K] for all U and K.

In addition to the above, I propose this should be generally inferred for TS functions, only required in type definitions.

Use Cases

This would enable you to patch the Array<T> type appropriately to allow the obvious type ReadonlyArray<T> = Readonly<Array<T>>. Conveniently, if you patch all the appropriate methods, you could even ensure it's covariant. But this wouldn't be the only area where it'd help, such as:

  • Readonly transactions, where you could define transaction(storeName: string, mode: "readonly"): Readonly<IDBTransaction> to enforce readonly-ness of that transaction at the type level.
  • Enforcing immutability around maps, sets, and the like.

Examples

interfaceArray<T>{splice(index: number,remove: number, ...replacements: T[]): T[]mutates this[number],this["length"]push(value: T): numbermutatesthis[number],this["length"]pop(): Tmutates this[number],this["length"]}// An internal marker symboltypeSetSym=unique symbolinterfaceSet<T>{[SetSym]: neveradd(value: T): thismutates this[SetSym],this["size"]delete(value: T): booleanmutatesthis[SetSym],this["size"]clear(): voidmutates this[SetSym],this["size"]}// An internal marker symboltypeMapSym=unique symbolinterfaceMap<K,V>{[MapSym]: neverset(key: K,value: V): thismutates this[MapSym],this["size"]delete(key: K): booleanmutatesthis[MapSym],this["size"]clear(): voidmutates this[MapSym],this["size"]}// Little bit of setup code for the interesting stuff.interfaceIDBTransaction{objectStore(name: string): this extendsReadonly<IDBTransaction> ? Readonly<IDBObjectStore> : IDBObjectStore}// An internal marker symboltypeIDBObjectStoreSym=unique symbolinterfaceIDBObjectStore{[IDBObjectStoreSym]: neveradd(value: Value,key: Key): IDBRequestmutatesthis[IDBObjectStoreSym]clear(): IDBRequestmutatesthis[IDBObjectStoreSym]createIndex(name: string): IDBRequestmutatesthis[IDBObjectStoreSym]delete(key: Key|KeyRange): IDBRequestmutatesthis[IDBObjectStoreSym]deleteIndex(name: string): IDBRequestmutatesthis[IDBObjectStoreSym]put(value: Value,key: Key): IDBRequestmutatesthis[IDBObjectStoreSym]openCursor(): this extendsReadonly<IDBObjectStore> ? IDBCursor : IDBCursorWithValueopenKeyCursor(): IDBCursor}typeIDBCursorSym=unique symbolinterfaceIDBCursor{[IDBCursorSym]: never// All the usual propertiesadvance(count: number): voidmutates this[IDBCursorSym]continue(key?: Key): voidmutates this[IDBCursorSym]continuePrimaryKey(key: Key,primaryKey: Key): voidmutates this[IDBCursorSym]}interfaceIDBCursorWithValueextendsIDBCursor{delete(): voidupdate(): void}

Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Awaiting More FeedbackThis means we'd like to hear from more people who would be helped by this featureSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions