TypeScript: Can you abstract a static constructor method to return the inheriting class?

72 Views Asked by At

Question

See title

Background

I'm trying to implement a document parser, and it is a delimited file in which the first element of a row is the heading for what type of data is within the row. I have set up a set of classes that represent these different data segments, and they all inherit from Segment as a base class, and there is a superclass of SegmentPair when a segment indicates the start of a larger data set than just the current row.

Code

I'm trying to define a function that can be mapped across the 2D-array of values, like so (simplified version):

import { readFileSync } from 'fs';

const contents = readFileSync('x12.txt', 'utf-8');
const segments = contents.split(segmentDelimiter).map((segment: string) => segment.split(recordDelimiter));

const parsed: ISegment[] = segments.map(Document.segmentMapper);

As you can probably tell, ISegment is an interface implemented by Segment. I've also defined Document as class Document extends Segment which may or may not be a good design choice, but these definitions look somewhat like this:

// ./interfaces/ISegment.ts
export interface ISegmentConstructor {
  new(segment: string, delimiter: string): ISegment;

  segmentMapper(delimiter: string, segment: string, index: number, array: string[]): ISegment;
}

export interface ISegment {
  get header(): string;

  get parts(): string[];
}
// ./Segment.ts
import { StaticImplements } from 'common';

import { ISegment, ISegmentConstructor } from '../interfaces';

@StaticImplements<ISegmentConstructor>()
export class Segment implements ISegment {
  protected readonly delimiter: string;
  readonly raw: string;
  
  constructor(segment: string, delimiter: string) {
    this.raw = segment.trim();
    this.delimiter = delimiter;
  }

  static segmentMapper<T extends Segment>(delimiter: string, raw: string): T {
    return new Segment(raw, delimiter) as T;
  }
}

I didn't mention it before (forgot until now) but there's an intermediate class that extends Segment called ControlSegment. The only difference is that it has a controlNumber property, since some segments (usually pairs) have a checksum of some sort that validates they belong together.

// ./SegmentPair.ts
import { ISegment } from '../interfaces';
import { ControlSegment } from './ControlSegment';
import { Segment } from './Segment';

export class SegmentPair<T extends ControlSegment> extends ControlSegment {
  protected footer: T;
  protected readonly body: ISegment[];
  protected readonly bodyMap: Map<string, number[]>;

  constructor(segment: string, delimiter: string, body?: ISegment[], footer?: T) {
    // implementation details
  }

  extend<S extends this>(raw: string, index: number, array: string[]): S {
    // more implementation details
  }
}

Anyway, Document.segmentMapper is a very lengthy method, since it contains switch-case that maps each segment header to a class constructor. The static constructor pattern I chose was a common method segmentMapper that expects segmentMapper(delimiter: string, segment: string, index: number, array: string[]): Segment to match the rough signature of a call to Array<string>.prototype.map<ISegment>(). An excerpt of this code is as follows:

import { ISegment } from './interfaces';
import { Segment } from './Segment';
import { SegmentISA } from './SegmentISA';
import { SegmentTRN } from './SegmentTRN';

export class Document extends Segment implements ISegment {
  segments: Segment[];
  private segmentMap: Map<string, number[]>;
  
  constructor(raw: string) {
    const [ segments, segmentDelimiter, recordDelimiter ] = Document.detectDelimiter(raw);
    super(raw, segmentDelimiter);
    this.segmentMap = new Map<string, number[]>();
    this.segments = segments.map(this.segmentMapper.bind(this, recordDelimiter));
  }
  
  private static detectDelimiter(raw: string): [ string[], string, string ] {
    // implementation details
    return [ raw.split(segmentDelimiter), segmentDelimiter, recordDelimiter ];
  }

  segmentMapper(delimiter: string, segment: string, index: number, array: string[]): Segment {
    const [ header ] = segment.split(delimiter);
    const lookup = this.segmentMap.get(header) ?? [];
    lookup.push(index);
    this.segmentMap.set(header, lookup);
    
    switch(header) {
      case 'ISA':
        // Herein lies the problem (see explanation below)
        return SegmentISA.segmentMapper(delimiter, segment).extend(segment, index, array);
      case 'TRN':
        return SegmentTRN.segmentMapper(delimiter, segment);
      default:
        return Segment.segmentMapper(delimiter, segment);
    }
  }
}

Problem

So, this pattern is extremely verbose (there's 24 cases), but I'm not too bothered by long code as long as it's clean and easily understood. The problem I have is the SegmentISA.segmentMapper() returns a type Segment rather than SegmentISA, and Segment doesn't have the extend method that comes from SegmentPair.

How do I implement a static constructor method (like segmentMapper) that returns the inheritor's class rather than the implement's class? I know I could just write an override implementation at the inheritor's level, but I was hoping there would be a way to enforce it with less code. You can see I've added the type parameter to static segmentMapper<T extends Segment> as an ugly solution (IMO). I'm thinking I might implement a SegmentFactory class that has getters that return the typed segment so I can eliminate the switch-case and use something closer to import segmentFactory from './SegmentFactory'; const segment = segmentFactory[header](delimiter, segment, index, array); but I wanted to pose the question to the community.

0

There are 0 best solutions below