Skip to content

Self mutating typesΒ #47929

Closed
Closed

Description

@didii

Suggestion

πŸ” Search Terms

mutate generic, class method, type, changing, this type guard

βœ… Viability 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, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

Allow for mutating the this type inside a method for external users. Probably most notably useful in mutating the generics parameters of a certain class.

πŸ“ƒ Motivating Example

Fully typed arrays that mutate when using for example the .push method.

const array = [6, 'foo'] as [number, string];
array.push(99);
type NewArrayType = typeof array;
// We expect: [number, string, number]
// We get: [number, string]

πŸ’» Use Cases

In my specific use-case, I have a process and all sort of different kinds of steps in that process. Data is of course transferred between those steps. Having steps declare what data they need and output is a bit verbose, but possible. Creating the process object that can (at runtime) decide how to combine the existing steps and have compile-time checks on if those steps will have the necessary data available is currently extremely convoluted.

/** A step simply converts the given data to something new */
type Step<TInput, TOutput> = (input: TInput) => Promise<TOutput>;

/** A process is simply an array of steps that can be executed all in order */
class Process<TData> {
  public constructor(private initialData: TData) { }

  /** Keep a list of all steps we have built up */
  private steps: Step<any, any>[] = [];

  /** Append a step to the process. Note that the return type is used for the workaround only. */
  public push<TOutput>(step: Step<TData, TOutput>): this is Process<TOutput> {
    this.steps.push(step);
    return true;
  }

  /** Same as the above, but we'll return ourselves as a different type */
  public push2<TOutput>(step: Step<TData, TOutput>): Process<TOutput> {
    this.steps.push(step);
    return this as unknown as Process<TOutput>; // TS doesn't like this change in type since TOutput has no relation to TInput
  }

  /** Execute all steps one by one, feeding the result of the previous one to the next */
  public async run(): Promise<TData> {
    let data = this.initialData;
    for (const step of this.steps) {
      data = await step(data);
    }
    return data;
  }
}

// This step requires no data, and will create a property id
// Also, having TOutput be determined implicitely here from the lambda would be amazing
const setIdStep: Step<{}, { id: string }> = input => Promise.resolve({ id: '093c3d72-6b8b-413c-ad5e-8787226402c3' });
// This step requires an id and will create some data
const fetchDataStep: Step<{ id: string }, { id: string, data: { foo: number } }> = input => Promise.resolve({ ...input, data: { foo: 99 } });

// How we want to use this class without TS errors
async function desiredSyntax() {
  const p = new Process({});
  p.push(setIdStep);
  p.push(fetchDataStep); //<- Error: Property 'id' is missing in type '{}' but required in type '{ id: string; }'.
  const result = await p.run();
  console.log(result.data.foo); //<- Error: Property 'data' does not exist on type 'Promise<{}>'.
}

// How to fool TS in thinking it is doing it correctly
async function workaround() {
  const p = new Process({});
  if (p.push(setIdStep)) {
    if (p.push(fetchDataStep)) {
      const result = await p.run(); // { id: string, data: any }
      console.log(result.data.foo)
    }
  }
}

// Another workaround, but this gets even more error prone that the previous one
async function workaround2() {
  const p = new Process({});
  const p2 = p.push2(setIdStep); // Just assign a new variable which will have the correct type since we forced it to
  const p3 = p2.push2(fetchDataStep);
  const result = await p3.run();
  console.log(result.data.foo);
}

Note that both workaround methods work, but they add a lot of complexity to the code, are extremely error-prone and they do not properly reflect how it would be done in plain JS.
Additionally, this workaround forces us to have a fixed return type which is both too restrictive and unnecessary.

Activity

  1. MartinJohns commented on Feb 17, 2022

    @MartinJohns
    Contributor

    This sounds very much like #41339.

  2. RyanCavanaugh commented on Feb 17, 2022

    @RyanCavanaugh
    Member

    I agree this is effectively session typing.

    The entire sort of framework of this is problematic, since as soon as a mutating value gets aliased, the whole system becomes unsound because there's no reverse reference tracking going on (nor would we be inclined to add that) to invalidate those references.

  3. didii commented on Feb 17, 2022

    @didii
    Author

    Yes, that seems to be a close enough issue to mark this as a duplicate. I'll close this one off then, thanks.

    Just for completeness sake, this is how my use-case could be implemented using the assert this is <type> syntax as suggested in #41339.

    /** A step simply converts the given data to something new */
    type Step<TInput, TOutput> = (input: TInput) => Promise<TOutput>;
    
    /** A process is simply an array of steps that can be executed all in order */
    class Process<TData> {
      public constructor(private initialData: TData) { }
    
      /** Keep a list of all steps we have built up */
      private steps: Step<any, any>[] = [];
    
      /** Append a step to the process. Note that the return type is used for the workaround only. */
      public push<TOutput>(step: Step<TData, TOutput>): asserts this is Process<TOutput> {
        this.steps.push(step);
      }
    
      /** Execute all steps one by one, feeding the result of the previous one to the next */
      public async run(): Promise<TData> {
        let data = this.initialData;
        for (const step of this.steps) {
          data = await step(data);
        }
        return data;
      }
    }
    
    // This step requires no data, and will create a property id
    // Also, having TOutput be determined implicitely here from the lambda would be amazing
    const setIdStep: Step<{}, { id: string }> = input => Promise.resolve({ id: '093c3d72-6b8b-413c-ad5e-8787226402c3' });
    // This step requires an id and will create some data
    const fetchDataStep: Step<{ id: string }, { id: string, data: { foo: number } }> = input => Promise.resolve({ ...input, data: { foo: 99 } });
    
    // How we want to use this class without TS errors
    async function desiredSyntax() {
      // p now needs explicit typing for the assert to work
      const p: Process<{}> = new Process({});
      p.push(setIdStep); // p becomes Process<{id: string}>
      p.push(fetchDataStep); // p becomes Process<{id: string, data: {...}}>
      
      const result = await p.run();
      console.log(result.data.foo);
    }
  4. locked as resolved and limited conversation to collaborators on Oct 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    DuplicateAn existing issue was already created

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions