typescriptknockout.jstype-conversionknockout-mapping-plugin

Unwrap Typescript fields in Knockout


I have a domain class, with all the fields being primitives, string, number or boolean. For simplicity, let's work with a bike:

interface Bike {
  wheels: number;
  isMTB: boolean;
  brand: string;
}

In my application, I'm using knockout-mapping so I can easily make all the fields editable, e.g. wheels becomes ko.Observable<number>. I have created therefore a type to help me type it, e.g.

export type MappedObject<T> = {
  [key in keyof T]: ko.Observable<T[key]>;
};

I'm usually maintaining a collection of editable bikes, e.g. bikes = ko.observableArray<MappedObject<Bike>>();

And pushing one editable bike like this:

const bike = { wheels: 2, isMTB: true, brand: "Cross" }; // Bike
const editBike = ko.mapping.fromJS(bike); // MappedObject<Bike>
bikes.push(editBike);

When I'm saving my changes, I'm converting the collection of editable bikes into an array of plain bike objects using either ko.toJS or ko.mapping.toJS. Both unwraps any observable fields into their primitive value, so for bikes defined above, ko.toJS returns, at runtime, a Bike[]. However, in Typescript, it doesn't return that, it returns MappedObject<Bike>[].

For ko.toJS there is a definition:

type Unwrapped<T> = T extends ko.Subscribable<infer R> ? R :
    T extends Record<any, any> ? { [P in keyof T]: Unwrapped<T[P]> } : T;

export function toJS<T>(rootObject: T): Unwrapped<T>;

For knockout-mapping, there isn't any typing so I tried to define it myself:

export type UnmappedObject<T> = T extends ko.ObservableArray<MappedObject<infer U>>
  ? U[]
  : T extends ko.Observable<MappedObject<infer R>>
  ? R
  : T;

// Note: this is not containing ALL the properties exposed
export interface KnockoutMapping {
  fromJS: <T>(object: T) => MappedObject<T>;
  toJS: <T>(object: T) => UnmappedObject<T>;
}

Note that UnmappedObject<T> is inspired by Unwrapped<T> shipped with knockout, but it doesn't work as I expect because ko.mapping.toJS will return Unmapped<ObservableArray<MappedObject<Bike>>>. toJS should convert both editable object to plain object and array of editable objects to array of plain objects, hence my attempt to guess if T extends ko.ObservableArray or ko.Observable.

What can I change to have properly unwrapping for the fields of each editable object in the array? For clarity, here is what I expect:


const bike = { wheels: 2, isMTB: true, brand: "Cross" }; // Bike
const editBike = ko.mapping.fromJS(bike); // MappedObject<Bike>
const customizedBike = ko.mapping.toJS(editBike); // Bike

const bikes = ko.observableArray<MappedObject<Bike>>();
bikes.push(editBike);
const customizedBikes = ko.mapping.toJS(bikes); // Bike[]

Solution

  • I was close to the final solution, I was missing a few cases here and there:

    export type MappedObject<T> = {
      [key in keyof T]: ko.Observable<T[key]>;
    };
    
    export type UnmappedObject<T> = T extends ko.ObservableArray<MappedObject<infer U>>
      ? U[]
      : T extends ko.Observable<MappedObject<infer V>>
      ? V
      : T extends ko.Computed<MappedObject<infer S>>
      ? S
      : T extends ko.Computed<MappedObject<infer X>[]>
      ? X[]
      : T extends MappedObject<infer W>
      ? W
      : T;
    
    // Note: this is not containing ALL the properties exposed
    export interface KnockoutMapping {
      fromJS: <T>(object: T) => MappedObject<T>;
      toJS: <T>(object: T) => UnmappedObject<T>;
    }