1
votes

I have a third party library that returns objects (type OpaqueObject) containing fields that are opaque to typescript. For any given OpaqueObject, I know what fields are defined and/or required, but Typescript knows nothing. The "schema" of a given OpaqueObject simply lists the defined fields, with a boolean flag indicating whether they are required to be present:

type OpaqueObjectSchema = { [field: string]: boolean }

Fields are retrieved from an OpaqueObject via the getField function. getField uses a type predicate to modulate the return type depending on whether the field is required. Fetching a required field that isn't present throws an error. Here is the signature:

getField<B extends boolean>(
  obj: OpaqueObject, field: string, isRequired: B,
): B extends true ? string : (string | undefined)

I have a class factory function (opaqueObjectWrapperFactory) that takes in an OpaqueObjectSchema and outputs a wrapper class. For example, I have a type of OpaqueObject that has two fields: f1 and f2. f1 is required;f2 might be undefined. The hardcoded version of the class my factory produces is:

class OpaqueObjectWrapper {

  private obj: OpaqueObject

  constructor(obj: OpaqueObject) {
    this.obj = obj;
  }

  get f1(): string { return getField(this.obj, 'f1', true) }
  get f2(): string | undefined { return getField(this.obj, 'f2', false) }
}

// OpaqueObjectWrapper is equivalent to the return value of:
opaqueObjectWrapperFactory({
  f1: true,
  f2: false,
});

My problem is that I can't figure out how to make these generated classes legible to Typescript. The factory should work like this:

// this declaration should have the same effect as hardcoding `OpaqueObjectWrapper`
class OpaqueObjectWrapper extends opaqueObjectWrapperFactory({ f1: true, f2: false });

Clearly I need to somehow use generics, but I'm not sure how to derive the return interface from the input schema. Is this possible?

2
In get f1() it would be true, right? - Shivam Singla
@ShivamSingla Yes, sorry, fixed. - Sean Mackesey

2 Answers

1
votes

Check if this works for you-

declare function getField<B extends boolean>(
  obj: OpaqueObject, field: string, isRequired: B,
): B extends true ? string : (string | undefined)

type OpaqueObject = any

type OpaqueObjectSchema = {
    [k: string]: boolean
}

// get the required keys
// e.g type KlassRequiredKeys = 'f1'
type KlassRequiredKeys<S extends OpaqueObjectSchema> = ({
    [K in keyof S]: S[K] extends true ? K : never
})[keyof S]

// get the optional keys
// e.g type KlassOptionalKeys = 'f2'
type KlassOptionalKeys<S extends OpaqueObjectSchema> = ({
    [K in keyof S]: S[K] extends true ? never : K
})[keyof S]

// create two objects one with required keys and other with optional keys,
/// then merge them
type Klass<S extends OpaqueObjectSchema> = {
    [K in KlassRequiredKeys<S>]: string
} & {
    [K in KlassOptionalKeys<S>]?: string
}

// type constructor function returnd by the factory function
type Constructor<P extends OpaqueObjectSchema> = {
    new (obj:OpaqueObject): Klass<P>
}

function factory<S extends OpaqueObjectSchema>(schema: S): Constructor<S> {
    const ctor2 = class {
        _obj: OpaqueObject // private
        constructor(obj: OpaqueObject) {
            this._obj = obj
        }
    } as unknown as Constructor<S>

    for (let key in schema) {
            Object.defineProperty(ctor2.prototype, key, {
                get() {
                    return getField(
                        this._obj,
                        key,
                        schema[key]
                    )
                }
            })
    }
    return ctor2
}

const schema = {
    f1: true,
    f2: false,
} as const

type X = typeof schema

const OpaqueObjectWrapper = factory(schema)

const a1 = new OpaqueObjectWrapper({}) // ok
const a2 = new OpaqueObjectWrapper({f1: 343}) // ok
const a3 = new OpaqueObjectWrapper({f1: '343'}) // ok
const a4 = new OpaqueObjectWrapper({f1: '343', f2: 34}) // ok
const a5 = new OpaqueObjectWrapper({f1: '343', f2: '34'}) // ok

a3.f1 // string
a3.f2 // string | undefined

a3._obj // error

One problem is that signature of getField is little compromised, though the class returned by factory function is working perfectly working fine

Playground

1
votes

Deriving Object Type

An OpaqueObject is one where all of the values are string and some keys are required while others are optional. Let's first define a type assuming that we already know which is which:

type OpaqueObject<AllKeys extends string, RequiredKeys extends string> = Partial<Record<AllKeys, string>> & Record<RequiredKeys, string>

If we want to go from a schema to an object, we know that we need to find all of the keys -- which is easy as it's just keyof Schema -- and the required keys. We know that a key if required if it's value in the schema is true.

Important note: we must use as const when creating a schema in order to separate true from false, otherwise we just know that we have a boolean.

The required keys for a schema S are:

type RequiredKeys<S> = {
    [K in keyof S]: S[K] extends true ? K : never;
}[keyof S]

So we can now write a type for an OpaqueObject which depends only on the schema S.

type OpaqueObject<S> = Partial<Record<keyof S, string>> & Record<RequiredKeys<S>, string>

Getting Fields

Now to the getField function. We don't want to pass in a boolean required flag because we should know this already. Instead, let's make this dependent on generics schema S and key K:

function getField<S, K extends keyof S>(
    obj: OpaqueObject<S>, field: K
): OpaqueObject<S>[K] {
    return obj[field];
}

But honestly this whole function is made unnecessary if we have a properly typed object because we would get the right return type from accessing the property directly.

const exampleSchema = {
    f1: true,
    f2: false,
} as const;

type ExampleObject = OpaqueObject<typeof exampleSchema>

class OpaqueObjectWrapper {

  private obj: ExampleObject

  constructor(obj: ExampleObject) {
    this.obj = obj;
  }

  get f1(): string { return this.obj.f1 }
  get f2(): string | undefined { return this.obj.f2 }
}

Unknowns

I'm confused about the source of the schemas in regards to whether the as const is possible. Are these coming in as variables from an external source? or are you defining them by writing out objects in your code?

The part that's tripping me up about your question is implementing get f1() and get f2() in a dynamic way inside a class. Unlike other languages like PHP, Javascript doesn't have a dynamic getter where the property value is unknown. You can only do it through a Proxy.

Proxied Objects

The only way that I am aware of for how to dynamically get a property is by using a Proxy. I've got this mostly worked out. The part I am still missing is how to implement a construct signature on the proxied psuedo-class.

This proxy takes an instance of a class which stores an object as property obj and allows us to access properties of obj directly. In order for typescript to understand the added properties, we have to assert as Constructable<T> & T to say that all properties of T can be accessed.

const proxied = <T,>(inst: Constructable<T> ) => {
    return new Proxy( inst, {
        get: function <K extends keyof T>(oTarget: Constructable<T>, sKey: K): T[K] {
            return oTarget.obj[sKey];
        },
    }) as Constructable<T> & T
}

The underlying class I'm using to store the object is

// stores an object internally, but allows it to be created by calling new()
class Constructable<T> {

    private _obj: T

    constructor(obj: T) {
        this._obj = obj;
    }

    // object is readonly
    get obj(): T {
        return this._obj;
    }
}

So now we want to make a proxied class based on a schema. We want this:

interface ProxiedConstructable<T> {
    // pass in an object T and get something which can access of the properties of T
    new( args: T ): Readonly<T>;
}

I told you I wasn't all the way there and it's because the proxy applies to an instance of the class rather than the class itself, so I'm stuck on getting our factory to return something "new-able", but here's what I've got:

const makeProxied = <S extends { [field: string]: boolean }>(schema: S) => 
    (obj: OpaqueObject<S>) => {
        return proxied( new Constructable(obj) );
    }

Which works like this:

const test = makeProxied({
    f1: true,
    f2: false
} as const);

const testObj = test({f1: "hello world"});

const f1: string = testObj.f1;

const f2: string | undefined = testObj.f2;

Playground Link