Skip to content

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

Closed
@dead-claudia

Description

@dead-claudia

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:

type Mutates<T, Deps extends {O: unknown, K: keyof this["O"]}[]> = {
	0: Deps extends [] ? T :
		((d: Deps) => void) extends ((first: infer A, ...rest: infer B) => void) ?
			{readonly [P in A["K"]]: unknown} extends A["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:

type Mutates<T, Deps extends {O: unknown, K: keyof this["O"]}[]> = {
	0: Deps extends [] ? T :
		((d: Deps) => void) extends ((first: infer A, ...rest: infer B) => void) ?
			{readonly [P in A["K"]]: unknown} extends A["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

interface Array<T> {
	splice(index: number, remove: number, ...replacements: T[]): T[] mutates this[number], this["length"]
	push(value: T): number mutates this[number], this["length"]
	pop(): T mutates this[number], this["length"]
}

// An internal marker symbol
type SetSym = unique symbol
interface Set<T> {
	[SetSym]: never
	add(value: T): this mutates this[SetSym], this["size"]
	delete(value: T): boolean mutates this[SetSym], this["size"]
	clear(): void mutates this[SetSym], this["size"]
}

// An internal marker symbol
type MapSym = unique symbol
interface Map<K, V> {
	[MapSym]: never
	set(key: K, value: V): this mutates this[MapSym], this["size"]
	delete(key: K): boolean mutates this[MapSym], this["size"]
	clear(): void mutates this[MapSym], this["size"]
}

// Little bit of setup code for the interesting stuff.
interface IDBTransaction {
	objectStore(name: string): this extends Readonly<IDBTransaction> ? Readonly<IDBObjectStore> : IDBObjectStore
}

// An internal marker symbol
type IDBObjectStoreSym = unique symbol
interface IDBObjectStore {
	[IDBObjectStoreSym]: never
	add(value: Value, key: Key): IDBRequest mutates this[IDBObjectStoreSym]
	clear(): IDBRequest mutates this[IDBObjectStoreSym]
	createIndex(name: string): IDBRequest mutates this[IDBObjectStoreSym]
	delete(key: Key | KeyRange): IDBRequest mutates this[IDBObjectStoreSym]
	deleteIndex(name: string): IDBRequest mutates this[IDBObjectStoreSym]
	put(value: Value, key: Key): IDBRequest mutates this[IDBObjectStoreSym]
	openCursor(): this extends Readonly<IDBObjectStore> ? IDBCursor : IDBCursorWithValue
	openKeyCursor(): IDBCursor
}

type IDBCursorSym = unique symbol
interface IDBCursor {
	[IDBCursorSym]: never
	// All the usual properties
	advance(count: number): void mutates this[IDBCursorSym]
	continue(key?: Key): void mutates this[IDBCursorSym]
	continuePrimaryKey(key: Key, primaryKey: Key): void mutates this[IDBCursorSym]
}
interface IDBCursorWithValue extends IDBCursor {
	delete(): void
	update(): 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