Creating a sealed abstract class in TypeScript: A step-by-step guide

A sealed class in Kotlin is a unique type of abstract class where all its direct subclasses are known during compile time. These subclasses must be defined within the same module as the sealed class itself, preventing any external modules from extending it. This feature allows the Kotlin compiler to conduct exhaustiveness checks on the sealed class, similar to how TypeScript handles unions. The question arises whether a similar implementation can be achieved in TypeScript.

Let's analyze an abstract class called Expr, along with its direct subclasses, Num and Add.

abstract class Expr<A> {
  public abstract eval(): A;
}

class Num extends Expr<number> {
  public constructor(public num: number) {
    super();
  }

  public override eval() {
    return this.num;
  }
}

class Add extends Expr<number> {
  public constructor(public left: Expr<number>, public right: Expr<number>) {
    super();
  }

  public override eval() {
    return this.left.eval() + this.right.eval();
  }
}

An instance of the Expr class is demonstrated below.

// (1 + ((2 + 3) + 4)) + 5
const expr: Expr<number> = new Add(
  new Add(new Num(1), new Add(new Add(new Num(2), new Num(3)), new Num(4))),
  new Num(5)
);

The goal is to convert this instance into a right-associated expression.

// 1 + (2 + (3 + (4 + 5)))
const expr: Expr<number> = new Add(
  new Num(1),
  new Add(new Num(2), new Add(new Num(3), new Add(new Num(4), new Num(5))))
);

To achieve this transformation, we introduce a new abstract method called rightAssoc to the Expr class.

abstract class Expr<A> {
  public abstract eval(): A;

  public abstract rightAssoc(): Expr<A>;
}

Both the Num and Add subclasses implement this method accordingly.

class Num extends Expr<number> {
  public constructor(public num: number) {
    super();
  }

  public override eval() {
    return this.num;
  }

  public override rightAssoc(): Num {
    return new Num(this.num);
  }
}

class Add extends Expr<number> {
  public constructor(public left: Expr<number>, public right: Expr<number>) {
    super();
  }

  public override eval() {
    return this.left.eval() + this.right.eval();
  }

  public override rightAssoc(): Add {
    const expr = this.left.rightAssoc();
    if (expr instanceof Num) return new Add(expr, this.right.rightAssoc());
    if (expr instanceof Add) {
      return new Add(expr.left, new Add(expr.right, this.right).rightAssoc());
    }
    throw new Error('patterns exhausted');
  }
}

While this approach works correctly, there exists a potential issue. Within the Add#rightAssoc method, an error is thrown if the expr object does not belong to either the Num or Add classes. In case a new subclass of Expr is introduced, such as Neg, TypeScript might not warn about the incomplete instanceof checks. Is there a way to simulate sealed classes in TypeScript to ensure exhaustive checking for instanceof statements?

Answer №1

In TypeScript, a close alternative to the feature you're looking for is the concept of discriminated unions

// Base class
export abstract class ExprBase<A> {
  abstract type: string
  public abstract eval(): A;
  public abstract rightAssoc(): Expr;
}

// Discriminated union
type Expr = Num | Add | Neg;

class Num extends ExprBase<number> {
  type = "num" as const;
  public constructor(public num: number) {
    super();
  }

  public override eval() {
    return this.num;
  }

  public override rightAssoc(): Num {
    return new Num(this.num);
  }
  
}

// Function that ensures compiler error if the union is not checked exhaustively
function assertNever(a: never): never {
    throw new Error('patterns exhausted');
}
class Add extends ExprBase<number> {
  type = "add" as const;
  public constructor(public left: Expr, public right: Expr) {
    super();
  }

  public override eval(): number {
    return this.left.eval() + this.right.eval();
  }
  public override rightAssoc(): Add {
    const expr = this.left.rightAssoc();
    if (expr.type === "num") return new Add(expr, this.right.rightAssoc());
    if (expr.type === "add") {
      return new Add(expr.left, new Add(expr.right, this.right).rightAssoc());
    }
    // Error now, expr is Neg
    assertNever(expr);
  }
}

class Neg extends ExprBase<number> {
  type = "neg" as const;
  public constructor(public expr: Expr) {
    super();
  }

  public override eval(): number {
    return -this.expr.eval();
  }

  public override rightAssoc(): Neg {
    return new Neg(this.expr.rightAssoc());
  }
}

Playground Link

Answer №2

I was able to find a solution that allows for exhaustive pattern matching while preventing new classes from extending the sealed class. To achieve this, we introduced a new abstract method called match in the sealed class, Expr.

abstract class Expr<A> {
  public abstract match<B>(which: {
    Num: (expr: Num) => B;
    Add: (expr: Add) => B;
  }): B;

  public abstract eval(): A;

  public abstract rightAssoc(): Expr<A>;
}

The match method enables pattern matching on a defined set of direct subclasses, making it easy to implement the match method for these subclasses. For instance, here is how the Num#match method is implemented.

class Num extends Expr<number> {
  public constructor(public num: number) {
    super();
...

By following this approach, we can also implement the Add#match method and utilize it for implementing the Add#rightAssoc method. TypeScript ensures complete handling of all cases.

class Add extends Expr<number> {
  public constructor(public left: Expr<number>, public right: Expr<number>) {
    super();
...

Introducing a new subclass requires implementation of the match method, compelling updates to the sealed class's match method definition as well as all its uses to accommodate the new case.

Making the match method abstract also presents challenges in creating a subclass of Expr. Implementing the match method would require invoking handlers of known direct subclasses. Thus, for unknown subclasses, utilizing the match method would be semantically incorrect.

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

Struggling with getting render props to work in Next.js version 13

Looking to develop a custom component for Contentful's next 13 live preview feature in the app directory, I thought of creating a client component that can accept a data prop and ensure type safety by allowing a generic type to be passed down. Here is ...

Trigger ng-bootstrap modal programmatically

I have an Angular 4 page with a ng-bootstrap modal. My code is shown below. foo.html [...] <button class="btn btn-sm btn-secondary material-icons" (click)="open(content)">search</button> <ng-template #content let-c="close" let-d="dismiss" ...

Determine the return type based on the input parameter and a mapping strategy

I am encountering an issue with the inferred returnType of the following types. The problem lies in the cityAttractions type, where it should ideally have a type error in its return statement for returning a string instead of an array of strings. Playgrou ...

I'm curious about the significance of this in Angular. Can you clarify what type of data this is referring

Can anyone explain the meaning of this specific type declaration? type newtype = (state: EntityState<IEntities>) => IEntities[]; ...

What is the reason for a high-order generic function to eliminate falsy types from the argument list of the returned function?

Consider this example of a unique Decorator Factory Builder - a builder that constructs Decorator Factories to define metadata for forms: interface FormFieldOptions { name?: string; label?: string; format?: string; } type FormProperties = Record< ...

What is the best way to extract data from a request when using an HTTP callable function?

I've integrated the Firebase Admin SDK for handling administrative tasks. The functions I've set up are hosted on Firebase Cloud Function in my console. While I can successfully trigger these functions from my application, I'm facing an issu ...

I am attempting to incorporate an NPM package as a plugin in my Next.js application in order to prevent the occurrence of a "Module not found: Can't resolve 'child_process'" error

While I have developed nuxt apps in the past, I am new to next.js apps. In my current next.js project, I am encountering difficulties with implementing 'google-auth-library' within a component. Below is the code snippet for the troublesome compon ...

Unit testing in Angular 4 with Jasmine Spy: The expectation was for 'New text' but received undefined

I have a simple function in my app.component.ts that is meant to modify a parameter, and I am trying to test this function using a spy. However, for some reason, my changeText function always returns undefined. Can you help me identify what I might be doin ...

Issues arise with the escape key functionality when attempting to close an Angular modal

I have a component called Escrituracao that handles a client's billing information. It utilizes a mat-table to display all the necessary data. When creating a new bill, a modal window, known as CadastrarLancamentoComponent, is opened: openModalLancame ...

Typescript is struggling to locate a module that was specified in the "paths" configuration

Within my React project, I have set up a module alias in the webpack config. Now, I am looking to transition to Typescript. // I have tried to simplify the setup as much as possible Here is my tsconfig.json located in the root directory: { "compilerOp ...

Showing the child component as undefined in the view

Within my Angular application, I encountered an issue involving a parent component named DepotSelectionComponent and its child component SiteDetailsComponent. The problem arises when an event called moreDetails is emitted to the parent component, triggerin ...

Exploring the best practices for utilizing the error prop and CSS with the Input API in Material UI while utilizing context

When working with the MUI Input API props and CSS, I encountered an issue related to the {error} and its use. This is how my code looks: const [value, setValue] = useState<string>(cell.value); const [startAdornment, setStartAdornment] = useState< ...

Implement Material-UI's built-in validation for form submission

I'm in the process of setting up a form with validation: import React from 'react'; import { useForm } from "react-hook-form"; import axios, {AxiosResponse} from "axios"; import {Box, Button, Container, Grid, Typography} ...

"NameService is not provided in Angular, please check your module

I've been struggling with loading a class into my Angular component. I've spent quite some time trying to figure out the solution, even attempting to consolidate everything into a single file. Here is what I have: Application.ts /// <referenc ...

Oops! An error occurred: Uncaught promise in TypeError - Unable to map property 'map' as it is undefined

Encountering an error specifically when attempting to return a value from the catch block. Wondering if there is a mistake in the approach. Why is it not possible to return an observable from catch? .ts getMyTopic() { return this.topicSer.getMyTopi ...

Leverage the new Animation support in RC 5 to animate each item in an *ngFor list sequentially in Angular 2

I have a unique component that retrieves a list of items from the server and then displays that list using *ngFor in the template. My goal is to add animation to the list display, with each item animating in one after the other. I am experimenting with t ...

What could have caused this issue to appear when I tried running ng build --prod?

Issue encountered while trying to build the ng2-pdf-viewer module: An error occurred in the webpack loader (from @angular-devkit/build-optimizer) with the message: TypeError: Cannot read property 'kind' of undefined. This error is related to A ...

Utilizing the useSearchParams() function to retrieve live data in Next.js

Is there anyone who has successfully migrated from the pages router to the app router in Next.js? I am facing an issue with dynamic data migration. In the pages router, dynamic data is retrieved on a page using useRouter().query, but in the app router, it ...

Creating an interface that is heavily dependent on another interface, while only considering the input type for a generic class

I am currently working on an Angular reactive form and have defined an interface as follows: export interface LoginForm { username: FormControl<string>, password: FormConrtol<string> } My goal now is to create another interface that re ...

Leverage server-side data processing capabilities in NuxtJS

I am currently in the process of creating a session cookie for my user. To do this, I send a request to my backend API with the hope of receiving a token in return. Once I have obtained this token, I need to store it in a cookie to establish the user' ...