Last time, I created a reference implementation of the SpreadsheetData
interface, including support for editing. Connect it to my VirtualSpreadsheet
front end, and boom, you have an editable empty spreadsheet.
I want to do the same for the “fake” data sources in my Storybook and sample code. Fortunately, I have a cunning plan. I’m going to layer an editable empty data source on top of my existing data sources.
Layered Spreadsheet Data
LayeredSpreadsheetData
is an implementation of SpreadsheetData
that layers two other SpreadsheetData
instances on top of each other. There’s an “edit” layer on top where any changes are stored, with a “base” layer underneath. If a value is undefined
in the edit layer, the corresponding value is returned from the base layer instead.
I can use any of my fake data sources as a base layer, with SimpleSpreadsheetData
as the edit layer.
I want my implementation to be type safe so it’s natural for LayeredSpreadsheetData
to be generic on BaseData
and EditData
types.
Down the Typing Rat Hole
As is now becoming familiar where generics are involved, it took quite a journey to get the typing right.
Generic Parameter Constraints
LayeredSpreadsheetData
will be calling methods on the base and edit SpreadsheetData
implementations so I need to constrain the generic type parameters so that TypeScript knows what they’re meant to be.
I started with some simple code to sketch out how the generic parameters would work.
class LayeredSpreadsheetData<BaseData extends SpreadsheetData,
EditData extends SpreadsheetData> {
constructor(base: BaseData, edit: EditData) { ... }
}
Which results in a TypeScript error, “Generic type SpreadsheetData requires 1 type argument”.
Parameterized Generic Parameters
Apparently you can’t use a generic type as a type parameter. TypeScript doesn’t support “higher kinded types”.
You have to use an explicit type. For example, TypeScript is happy with BaseData extends SpreadsheetData<unknown>
. As we saw last time, this would allow consumers to use any instantiation of SpreadsheetData
.
The problem is that it would remove type safety from my implementation. There would be no type error if I pass the wrong thing as a snapshot, including passing a base
snapshot to edit
or vice vera.
We need to parameterize the snapshot type.
class LayeredSpreadsheetData<BaseData extends SpreadsheetData<BaseSnapshot>,
EditData extends SpreadsheetData<EditSnapshot>> {
constructor(base: BaseData, edit: EditData) { ... }
}
Which results in the TypeScript error “Cannot find name BaseSnapshot”. TypeScript doesn’t automatically treat BaseSnapshot
as a type parameter.
Constraints using other type parameters
TypeScript does let you use other type parameters in generic constraints. What happens if I explicitly add BaseSnapshot
and EditSnapshot
as additional type parameters?
class LayeredSpreadsheetData<BaseData extends SpreadsheetData<BaseSnapshot>,
EditData extends SpreadsheetData<EditSnapshot>,
BaseSnapshot,
EditSnapshot> {
constructor(base: BaseData, edit: EditData) { ... }
}
Now TypeScript is happy. No errors.
I’m not happy. Now the caller has four type parameters to specify, two of which are redundant.
const data = new LayeredSpreadsheetData<EmptySpreadsheetData, SimpleSpreadsheetData,
number, SimpleSnapshot>(new EmptySpreadsheetData, new SimpleSpreadsheetData)
Inferring Generic Parameters
TypeScript will infer type parameters from constructor arguments. If I remove all the type parameters from the code above, TypeScript will infer the type LayeredSpreadsheetData<EmptySpreadsheetData, SimpleSpreadsheetData, unknown, unknown>
.
You can see how it got there. There’s no constraints on BaseSnapshot
and EditSnapshot
so unknown
is a reasonable choice given that every instantiation of SpreadsheetData
is compatible with SpreadsheetData<unknown>
.
More Constraints for Better Inference
I’ve previously made the argument that it’s fine to instantiate a generic class with unknown
as the implementation of the class is still type safe. However, it’s annoying that TypeScript can’t infer the more precise type. There’s also some impact on external type safety.
I will eventually need to define a LayeredSnapshot
type based on BaseSnapshot
and EditSnapshot
. In the unlikely event that I use two different instantiations of LayeredSpreadsheetData
I would end up with identical types for LayeredSnapshot
and no type errors if I mixed them up.
How about adding some constraints for BaseSnapshot
and EditSnapshot
? Typescript is great at manipulating types. You can use conditional types to extract the snapshot type from an instantiation of SpreadsheetData
export type SnapshotType<T> = T extends SpreadsheetData<infer TResult> ? TResult : never;
Let’s try using that in a constraint.
class LayerSpreadsheetData<BaseData extends SpreadsheetData<BaseSnapshot>,
EditData extends SpreadsheetData<EditSnapshot>,
BaseSnapshot extends SnapshotType<BaseData>,
EditSnapshot extends SnapshotType<EditData> {
constructor(base: BaseData, edit: EditData) { ... }
}
TypeScript complains that “type parameter BaseSnapshot has a circular constraint”. I guess it has a point.
Generic Parameter Defaults
To confirm that the definition of SnapshotType
is correct, I tried using it in an instantiation of LayeredSpreadsheetData
.
const data = new LayeredSpreadsheetData<EmptySpreadsheetData, SimpleSpreadsheetData,
SnapshotType<EmptySpreadsheetData>,
SnapshotType<SimpleSpreadsheetData>>(new EmptySpreadsheetData, new SimpleSpreadsheetData)
That works and gives me the type I want. It also led me to the answer.
TypeScript supports defaults for generic type parameters. I can provide default instantiations for BaseSnapshot
and EditSnapshot
equivalent to the code above.
class LayeredSpreadsheetData<BaseData extends SpreadsheetData<BaseSnapshot>,
EditData extends SpreadsheetData<EditSnapshot>,
BaseSnapshot = SnapshotType<BaseData>,
EditSnapshot = SnapshotType<EditData>> {
constructor(base: BaseData, edit: EditData) { ... }
}
Which actually works. Both new LayeredSpreadsheetData(...)
and new LayeredSpreadsheet<EmptySpreadsheetData, SimpleSpreadsheetData>(...)
infer the correct explicit type.
Layered Snapshot
LayeredSpreadsheetData
needs to forward calls on to the base
and edit
layers, which means that LayeredSnapshot
needs to contain an instance of BaseSnapshot
and EditSnapshot
.
interface LayeredSnapshotContent<BaseSnapshot, EditSnapshot> {
base: BaseSnapshot,
edit: EditSnapshot
}
I used the same approach as last time to define an internal snapshot type with a separate opaque branded type for external use. The branding needs to make use of the generic type parameters to ensure that different instantiations have distinct public snapshot types.
export enum _LayeredSnapshotBrand { _DO_NOT_USE="" };
export interface LayeredSnapshot<BaseSnapshot,EditSnapshot> {
/** @internal */
_brand: [ _LayeredSnapshotBrand, BaseSnapshot, EditSnapshot ]
}
Declaring type parameters without using them in the interface definition has no effect. I turned the _brand
field into an array and added them to it. Remember that the _brand
field is never used. It just has to be there in the declaration for when TypeScript does its structural typing analysis.
Complete class declaration
It’s taken a while but we now have all the pieces needed to put the complete class declaration together.
export class LayeredSpreadsheetData<BaseData extends SpreadsheetData<BaseSnapshot>,
EditData extends SpreadsheetData<EditSnapshot>,
BaseSnapshot = SnapshotType<BaseData>,
EditSnapshot = SnapshotType<EditData>>
implements SpreadsheetData<LayeredSnapshot<BaseSnapshot, EditSnapshot>> {
constructor(base: BaseData, edit: EditData) {
this.#base = base;
this.#edit = edit;
}
#base: BaseData;
#edit: EditData;
#content: LayeredSnapshotContent<BaseSnapshot, EditSnapshot> | undefined;
}
Implementation
Let’s hope the implementation is simpler than sorting out the typing was.
Get Snapshot
We keep a copy of the most recent snapshot in #content
. We create a new snapshot if either base or edit snapshot have changed, or if we don’t have a cached snapshot yet.
getSnapshot(): LayeredSnapshot<BaseSnapshot, EditSnapshot> {
const baseSnapshot = this.#base.getSnapshot();
const editSnapshot = this.#edit.getSnapshot();
if (!this.#content || this.#content.base != baseSnapshot ||
this.#content.edit != editSnapshot) {
this.#content = { base: baseSnapshot, edit: editSnapshot } ;
}
return asSnapshot(this.#content);
}
Subscribe
Equally simple. We forward the subscriber onto the base and edit layers, returning a thunk that unsubscribes from both.
subscribe(onDataChange: () => void): () => void {
const unsubscribeBase = this.#base.subscribe(onDataChange);
const unsubscribeEdit = this.#edit.subscribe(onDataChange);
return () => {
unsubscribeBase();
unsubscribeEdit();
}
}
Setters and Getters
The rest of the implementation is just a matter of forwarding calls on to the appropriate place and processing the results.
All edits go to the edit layer.
setCellValueAndFormat(row: number, column: number,
value: CellValue, format: string | undefined): boolean {
return this.#edit.setCellValueAndFormat(row, column, value, format);
}
All value and format queries go to the edit layer if the corresponding cell is defined, otherwise the base layer.
getCellValue(snapshot: LayeredSnapshot<BaseSnapshot, EditSnapshot>,
row: number, column: number): CellValue {
const content = asContent(snapshot);
const editValue = this.#edit.getCellValue(content.edit, row, column);
if (editValue !== undefined)
return editValue;
return this.#base.getCellValue(content.base, row, column);
}
getCellFormat(snapshot: LayeredSnapshot<BaseSnapshot, EditSnapshot>,
row: number, column: number): string | undefined {
const content = asContent(snapshot);
const editValue = this.#edit.getCellValue(content.edit, row, column);
return (editValue === undefined) ? this.#base.getCellFormat(content.base, row, column)
: this.#edit.getCellFormat(content.edit, row, column);
}
Row and column counts use the maximum value from the two layers.
getColumnCount(snapshot: LayeredSnapshot<BaseSnapshot, EditSnapshot>): number {
const content = asContent(snapshot);
return Math.max(this.#base.getColumnCount(content.base),
this.#edit.getColumnCount(content.edit));
}
The ItemOffsetMapping
getters are complicated to do in a general way. For now, I just forward on to the base layer. That works perfectly for the current use case where I have fake data in the base layer and an empty edit layer. I haven’t exposed a way of changing the extent of a row or column so no need to worry about merging the layers together for now.
getColumnItemOffsetMapping(snapshot: LayeredSnapshot<BaseSnapshot, EditSnapshot>): ItemOffsetMapping {
return this.#base.getColumnItemOffsetMapping(asContent(snapshot).base);
}
Try It!
Visit the Test Data Virtual Spreadsheet story. Also embedded right here for your convenience.
Try changing some of the existing values. Scroll your changes out of view and come back. Are the changes still there?
Scroll down to the end of the data and watch how additional rows are being added to the base layer. Change values a few rows ahead and see what happens as the additional rows catch up.
Conclusion
I spent a lot more time on typing than the actual implementation. Was it worth it?
I learned a lot more about the TypeScript type system. Learning new things is always worthwhile.
The strict typing actually helped me during implementation. As I figured out which layers to forward calls on to, I often ended up changing the SpreadsheetData
instance I was calling. VS Code Intellisense immediately reminded me that I needed to change the snapshot I was passing in too.