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.