State

Changing functionality of a class in a maintainable and scalable way

This pattern is just borderline magic. It is a way to change how a class works in such a way that encapsulation is not broken, private fields stay private, and class' internal state is not violated. The following code example describes what actions are possible to be performed on a work item depending on what situation it is in.
A work item can be in one of the following statuses:
  1. New
  2. Technician Assigned
  3. Quality Assurance (QA) review
  4. Resolved
A work item can go back and forth between statuses, but it cannot skip statuses. Each status dictates what can be done to the work item. For example:
  • In "New", the only possible thing is to assign technician.
  • In "Technician Assigned", work item can be rolled back to "New" (technician needs to be unassigned) or moved forward to "QA review," assigning a QA specialist to the work item.
  • In "QA review", work item may be resolved, adding an optional comment, or rolled back to "Technician Assigned", unassigning the QA specialist from the item.
  • In "Resolved", a work item can only be rolled back to "QA review," removing the completion status and comment.

Seems like it can be solved by a giant if statement in each of the methods, checking status each time an action needs to be performed, and acting accordingly. And it's not wrong, for certain cases, this pattern may be an overkill. The State pattern though solves this problem by another layer of indirection, and outsourcing all movement actions to a currentState variable that is of type SupportTicketState, that uses the following abstract class:
abstract class SupportTicketState {
  state: string;
  ticket: SupportTicket;
  abstract assignTechnician(techName: string);
  abstract assignQA(qaName: string);
  abstract resolve(result: boolean, resolutionComment: string);
  abstract moveBack(): void;
}
So then the trick is that at each state, the state is replaced with a object of a different class that knows how to do things differently. Let's get to it. Below is the SupportTicket class, it logs what's happening at creation time for visibility, and then it has four methods that are used to perform actions, but in reality all actions are outsourced to a variable of type SupportTicketState. When a situation changes, that variable is reassigned to an instance of a class that knows how to act under new circumstances.
class SupportTicket {
  id: string;
  description: string;
  assignedTechnician: string;
  assignedQA: string;
  isResolved: boolean;
  resolutionComment: string;

  currentState: SupportTicketState;
  constructor(id: string = "N/A", description: string = "N/A") {
    this.id = id;
    this.description = description;
    console.log(`Created support ticket ${this.id}.`);
    this.currentState = new SupportTicketState_New(this);
  }
  assignTechnician(techName: string) {
    this.currentState.assignTechnician(techName);
  }
  assignQA(qaName: string) {
    this.currentState.assignQA(qaName);
  }
  resolve(result: boolean, resolutionComment: string = "N/A") {
    this.currentState.resolve(result, resolutionComment);
  }
  moveBack() {
    this.currentState.moveBack();
  }
}
The following class can instantiate object of "New" state. Constructor explains that the state is new. Then assignTechnician method sets technician name on the ticket, and then modifies the ticket such that its state is an instance of a SupportTicketState_TechnicianAssigned class, essentialy removing itself from the equation. All other methods are just stubs telling the user that those actions are not possible at the moment.
class SupportTicketState_New extends SupportTicketState {
  constructor(ticket: SupportTicket) {
    super();
    this.ticket = ticket;
    console.log(`Support ticket ${this.ticket.id} is now in New state.`);
  }
  assignTechnician(techName: string) {
    this.ticket.assignedTechnician = techName;
    console.log(
      `Ticket ${this.ticket.id} has been assigned a QA tech: ${techName}.`
    );
    this.ticket.currentState = new SupportTicketState_TechnicianAssigned(
      this.ticket
    );
  }
  assignQA(qaName: string) {
    console.log("Assign QA: Cannot assign QA while ticket is New.");
  }
  resolve(result: boolean, resolutionComment: string) {
    console.log(
      `Resolve: Ticket ${this.ticket.id} is in the 'New' status. Cannot resolve a new ticket.`
    );
  }
  moveBack(): void {
    console.log(
      `Move back: Ticket ${this.ticket.id} is in the 'New' status. Cannot move back because there is no state prior to New.`
    );
  }
}
Here is the code for the other states. Similar ideas as above. If the ticket is at the state where something can be done, then it is done. If not, user is warned about it. If the ticket is moved back, outdated information is wiped out. If conditions change, then the ticket's currentState variable is changed to an instance of another, more appropriate class.

    //Technician assigned, can either move back or assign QA
class SupportTicketState_TechnicianAssigned extends SupportTicketState {
  constructor(ticket: SupportTicket) {
    super();
    this.ticket = ticket;
    console.log(
      `Support ticket ${this.ticket.id} is now in \'Technician Assigned\' state.`
    );
  }
  assignTechnician(techName: string) {
    console.log(
      `Assign Tech: Technician ${this.ticket.assignedTechnician} is already assigned on ticket ${this.ticket.id}.`
    );
  }
  assignQA(qaName: string) {
    this.ticket.assignedQA = qaName;
    console.log(
      `Ticket ${this.ticket.id} has been assigned a QA tech: ${qaName}.`
    );
    this.ticket.currentState = new SupportTicketState_QAReview(this.ticket);
  }
  resolve(result: boolean, resolutionComment: string) {
    console.log(
      `Resolve: Ticket ${this.ticket.id} is in the 'Technician Assigned' state. It can either have a QA assigned or be moved back to 'New'.`
    );
  }
  moveBack(): void {
    console.log(
      `Move back: Moving ticket ${this.ticket.id} from 'Technician Assigned' to 'New' state.`
    );
    this.ticket.assignedTechnician = undefined;
    this.ticket.currentState = new SupportTicketState_New(this.ticket);
  }
}
//QA Review, can either move back to Technician Assigned or forward to Resolved
class SupportTicketState_QAReview extends SupportTicketState {
  constructor(ticket: SupportTicket) {
    super();
    this.ticket = ticket;
    console.log(`Support ticket ${this.ticket.id} is now in 'QA Review' state.`);
  }
  assignTechnician(techName: string) {
    console.log(
      `Assign Tech: Technician ${this.ticket.assignedTechnician} is already assigned on ticket ${this.ticket.id}.`
    );
  }
  assignQA(qaName: string) {
    console.log(
      `Assign QA: QA Specialist ${this.ticket.assignedQA} is already assigned on ticket ${this.ticket.id}.`
    );
  }
  resolve(result: boolean, resolutionComment: string = null) {
    this.ticket.isResolved = result;
    if (resolutionComment) this.ticket.resolutionComment = resolutionComment;
    this.ticket.currentState = new SupportTicketState_Resolved(this.ticket);
  }
  moveBack(): void {
    console.log(
      `Move back: Moving ticket ${this.ticket.id} from 'QA Review' to 'Technician Assigned' state.`
    );
    this.ticket.assignedTechnician = null;
    this.ticket.currentState = new SupportTicketState_TechnicianAssigned(
      this.ticket
    );
  }
}
//Resolved, can only be moved back to QA assigned state:
class SupportTicketState_Resolved extends SupportTicketState {
  constructor(ticket: SupportTicket) {
    super();
    this.ticket = ticket;
    console.log(
      `Support ticket ${
        this.ticket.id
      } is now in 'Resolved' state. Resolved successfully: ${
        this.ticket.isResolved
      }, comment: ${
        this.ticket.resolutionComment
          ? this.ticket.resolutionComment
          : "No comment provided"
      }.`
    );
  }
  assignTechnician(techName: string) {
    console.log(
      `Assign Tech: Technician ${this.ticket.assignedTechnician} is already assigned on ticket ${this.ticket.id}.`
    );
  }
  assignQA(qaName: string) {
    console.log(
      `QA Specialist ${this.ticket.assignedQA} is already assigned on ticket ${this.ticket.id}.`
    );
  }
  resolve(result: boolean, resolutionComment: string = null) {
    console.log(`Resolve: Ticket ${this.ticket.id} has already been resolved.`);
  }
  moveBack(): void {
    console.log(
      `Move back: Moving ticket ${this.ticket.id} from 'Resolved' to 'QA Review' state.`
    );
    this.ticket.isResolved = null;
    this.ticket.resolutionComment = null;
    this.ticket.currentState = new SupportTicketState_QAReview(this.ticket);
  }
}
Here is the code that runs the example above. Some methods are commented out because they are the ones that can actually work at each stage, so they are not called to show all the edge cases.
let supportTicket1 = new SupportTicket("12345", "test ticket");

supportTicket1.moveBack(); //error
//supportTicket1.assignTechnician("Jeff the Tech");
supportTicket1.assignQA("Jane the QA");
supportTicket1.resolve(true, "Finished successfully");
console.log("--- Moving to Tech Assigned State ---");

supportTicket1.assignTechnician("Jeff the Tech");

supportTicket1.assignTechnician("Jeff the Tech");
//supportTicket1.assignQA("Jane the QA");
supportTicket1.resolve(true, "Finished successfully");
console.log("--- Moving to QA Assigned State ---");
supportTicket1.assignQA("Jane the QA");

supportTicket1.assignTechnician("Jeff the Tech");
supportTicket1.assignQA("Jane the QA");
//supportTicket1.resolve(true, "Finished successfully");
console.log("--- Moving to Resolved State ---");
supportTicket1.resolve(true, "Finished successfully");

console.log("--- Moving all the way back to New State: ---");

supportTicket1.moveBack();
supportTicket1.moveBack();
supportTicket1.moveBack();
supportTicket1.moveBack();
console.log("--- Moving all the way forward without errors:  ---");
supportTicket1.assignTechnician("Jeff the Tech");
supportTicket1.assignQA("Jane the QA");
supportTicket1.resolve(true, "Finished successfully again!");
End result:

Created support ticket 12345.
Support ticket 12345 is now in New state.
Move back: Ticket 12345 is in the 'New' status. Cannot move back because there is no state prior to New.
Assign QA: Cannot assign QA while ticket is New.
Resolve: Ticket 12345 is in the 'New' status. Cannot resolve a new ticket.
--- Moving to Tech Assigned State ---
Ticket 12345 has been assigned a QA tech: Jeff the Tech.
Support ticket 12345 is now in 'Technician Assigned' state.
Assign Tech: Technician Jeff the Tech is already assigned on ticket 12345.
Resolve: Ticket 12345 is in the 'Technician Assigned' state. It can either have a QA assigned or be moved back to 'New'.
--- Moving to QA Assigned State ---
Ticket 12345 has been assigned a QA tech: Jane the QA.
Support ticket 12345 is now in 'QA Review' state.
Assign Tech: Technician Jeff the Tech is already assigned on ticket 12345.
Assign QA: QA Specialist Jane the QA is already assigned on ticket 12345.
--- Moving to Resolved State ---
Support ticket 12345 is now in 'Resolved' state. Resolved successfully: true, comment: Finished successfully.
--- Moving all the way back to New State: ---
Move back: Moving ticket 12345 from 'Resolved' to 'QA Review' state.
Support ticket 12345 is now in 'QA Review' state.
Move back: Moving ticket 12345 from 'QA Review' to 'Technician Assigned' state.
Support ticket 12345 is now in 'Technician Assigned' state.
Move back: Moving ticket 12345 from 'Technician Assigned' to 'New' state.
Support ticket 12345 is now in New state.
Move back: Ticket 12345 is in the 'New' status. Cannot move back because there is no state prior to New.
--- Moving all the way forward without errors:  ---
Ticket 12345 has been assigned a QA tech: Jeff the Tech.
Support ticket 12345 is now in 'Technician Assigned' state.
Ticket 12345 has been assigned a QA tech: Jane the QA.
Support ticket 12345 is now in 'QA Review' state.
Support ticket 12345 is now in 'Resolved' state. Resolved successfully: true, comment: Finished successfully again!.
This pattern may seem similar to the Chain of Responsibility pattern. The only similarity is that an item is undergoing some process changes moving from one stage to another. However, the idea behind the Chain of Responsibility is that those states are objects foreign to the subject item. And they are also chained, so no way to go back and forth. The idea in this pattern is that the object reacts to changes in the environment and acts differently. State pattern is meant to separate concerns of a "state machine" into aspects that are easier to maintain.