Home > OS >  How to increase type-safety when generically indexing into generic types
How to increase type-safety when generically indexing into generic types

Time:11-05

To get more acquainted with the concept, I have written an implementation of the Lens-concept in TypeScript (i.e. a construct to facilitate simpler projection from and updates to immutable data structures).

For ease-of-use, I have added auxiliary static constructors from property-names and -types to the default implementation of the Lens-interface.

Now I am wondering if can make the signatures of these auxiliary constructors more type-safe.

The main interface is

interface Lens<I, P> {
    get(i: I): P
    update(i: I, pr: P): I
    compose<P2>(l2: Lens<P, P2>): Lens<I, P2>
    zipWith<P2>(l2: Lens<I, P2>): Lens<I, [P, P2]>
}

On the default implementation (GenericLens) there is a primary constructor which just takes functions for the getter and updater.

Furthermore, there is an auxiliary static constructor for creating a Lens given the I and P types as well as an argument specifying the name of the property (of type P) to project to from I. This constructor has the following signature:

static forProperty<I extends object, P extends I[keyof I]>(name: keyof I): Lens<I, P>

Finally, there is an auxiliary static constructor for creating a Lens to a tuple of fields in I (with the advantage over zipping individual lenses that it can be constructed in one go and does not entail having to project to / update from nested pairs). This constructor has the following signature:

static forProperties<I extends object, P extends Array<I[keyof I]>>(names: Array<keyof I>): Lens<I, P>

Assume we have the following types:

type City = string
type StreetLine = string
type ZipCode = string
type Country = string

type PostalAddress = { 
    readonly streetLine1: StreetLine, 
    readonly streetLine2: StreetLine|null,
    readonly zipCode: ZipCode, 
    readonly city: City, 
    readonly country: Country,
    readonly someNumber: number,
    readonly someBool: boolean
}

To get a Lens for, say, the city-property of PostalAddress, we would use the first auxiliary constructor as follows:

const postalAddressCityLens = GenericLens.forProperty<PostalAddress, City>("city")

To get a Lens for, say, a [Country, number, boolean]-tuple from PostalAddress, we would use the second auxiliary constructor as follows:

const postalAddressCountryNumberBooleanLens = 
  GenericLens.forProperties<PostalAddress, [Country, number, boolean]>(["country", "someNumber", "someBool"])

As mentioned, I am wondering if I can make these signatures more type safe. The main current problem for the first auxiliary fromProperty-constructor is that I do not know of a good way to improve the signature by type hinting that the name-argument has to be the name of a property of I which actually has type P.

Right now the compiler notices if the type P does not exist in I and if the name-argument is not a valid name of any property of I, but it does not catch when the type of the property of I given by the name-argument does not match the type P.

I know these issues can be alleviated by changing the signature to the following:

static forProperty<I extends object, K extends keyof I, P extends I[K]>(name: K): Lens<I, P>

which would be called as follows in the identical case as the above example:

const postalAddressCityLens = GenericLens.forProperty<PostalAddress, 'city', City>('city')

In general, it seems I cannot solely use generic type-arguments since AFAIK I cannot reify a generic literal type into a its literal value at runtime in order to actually index into the I type since generic types are erased - and if I only have the property-name as an argument, but not as a generic literal type the compiler cannot check whether the actual type matches the required type.

The above "improvement" requires me to pass the same information about the property name twice, but I currently don't see an alternative which also alleviates the issues without requiring such a duplication of information. Is there way to do this?

For the second auxiliary fromProperties-constructor, the problems are analogous but more extensive and more severe. The compiler also cannot check whether the names-argument contains names of properties of I which actually have the types in the P-tuple.

Additionally, it has the following issues:

  • No check whether the names-argument contains a reference to a property for each of the the types in P
  • No check whether the names-argument contains only names for properties with types actually in P
  • No check whether the lengths of the generic tuple-type P and the names-argument is identical
  • No check whether types of the properties referenced by the names-argument appear in the same order as in P.

Contrary to the first aux. constructor, I currently know of no potential changes to the signature that would alleviate the issues (even when we accept having to pass the same information twice).

The question here is also: Are there improvements that can be made to the signature in order to alleviate these issues (as far as possible)?

Here is an implementation of the Lens-interface which can be used for trying things out / validating ideas:

class GenericLens<I, P> implements Lens<I, P> {

    constructor(protected p: (i:I) => P, protected u: (i:I, p:P) => I){}
    get = this.p
    update = this.u
    
    compose<P2>(l2: Lens<P, P2>): Lens<I, P2> {
      return new GenericLens(
        (i:I): P2 => l2.get(this.p(i)), 
        (i:I, p2r: P2): I => 
          this.update(
            i, 
            l2.update(this.get(i), p2r)
          )
      )
    }
    
    zipWith<P2>(l2: Lens<I, P2>): Lens<I, [P, P2]> {
        return new GenericLens(
            (i: I): [P, P2] =>
              [this.get(i), l2.get(i)],
            (i: I, p: [P, P2]) =>  {
                const iWithPReplaced = this.update(i, p[0])
                const iWithPAndP2Replaced = l2.update(iWithPReplaced, p[1])
                return iWithPAndP2Replaced
            }
        )
    }
    
    static forProperty<I extends object, P extends I[keyof I]>(name: keyof I): Lens<I, P> {
        const projector = (i: I): P => i[name] as P
        const updater =  (i: I, newVal: P) => {
            if (typeof i[name] !== typeof newVal) {
                return i
            }
            const iCopy = Object.create(i)
            Object.assign(iCopy, i)
            const newProp = {}
            //@ts-ignore
            newProp[name] = newVal
            Object.assign(iCopy, newProp)
            return iCopy
        }
        return new GenericLens(projector, updater)
    }
    
    static forProperties<I extends object, P extends Array<I[keyof I]>>(names: Array<keyof I>): Lens<I, P> {
        const projector = (i:I) => {
            const tuple: P = [] as Array<I[keyof I]> as P
            for (const name of names) {
              tuple.push(i[name])
            }
            return tuple
        }
        const updater = (i:I, newData: P) => {
            const iCopy = Object.create(i)
            Object.assign(iCopy, i)
            let it = 0
            for (const name of names) {
                const newProp = {}
                //@ts-ignore
                newProp[name] = newData[it]
                Object.assign(iCopy, newProp)
                it  
            }
            return iCopy
        }
        return new GenericLens(projector, updater)
    }
}

This implementation could likely be cleaner in some places, but implementation-details are not my current concern.

Here is a Playground-link for the code with working examples.


Thanks to the insights from the accepted answer, I was able extend the functionality by defining AdaptiveLens<I, P, PR, O> and providing a default implementation (with usage-examples). This component can adapt the schema during the update process, replacing P in I with PR to give a new return type O.

I was able to implement forProperty and even forProperties for AdaptiveLens, though the latter required using the currying-workaround multiple times with type-parameters needing to be passed in between (not pretty but still quite workable).

Both methods are type-safe and have maximally narrowed return-types: The AdaptiveLens constructed by fromProperty has an update-method return-type of Omit<I, K> & Record<K, PR> where K is the literal type for the property-name specified, meaning the return-type is like I, except with the type of the specified property replaced by the new type PR.

The AdaptiveLens constructed by fromProperties has an update-method return-type of Omit<I, K[number]> & { [PK in keyof PR]: PR[PK]} where K is the tuple of literal-types for the specified properties, meaning the return-type is like I, except with all the specified properties removed and replaced with the "contents" of the PR-type.

The code with working examples can be found at this playground

CodePudding user response:

Your main problem here is that TypeScript doesn't directly support partial type argument inference of the sort requested in microsoft/TypeScript#26242. You have two generic type parameters, I and P, and need to explicitly specify I (because there's no value of type I you're using), but you'd like to be able to infer P from the name/names value you're passing in. But when you call a generic function in TypeScript, you can either explicitly specify all of the type arguments, or you need to ask the compiler to infer all of the type arguments.

There are various workarounds... the one I usually recommend is generic currying, in which you turn a single generic function of multiple type parameters into a generic function of just some of those type parameters that returns another generic function of the rest of the type parameters. That forces you to call two functions where you only want to call one, but that allows you to specify the type arguments for the first and get inference for the second.

Oh, and while we're refactoring, you'll find it much easier to make your function generic in K, the type of the name/names parameter, and use that to compute P, than to do the reverse:

When K is the type of name, then P is the indexed access type I[K], since you are looking up the property of type I at key K. So that makes forProperty() look like this:

static forProperty<I extends object>() {
    return function <K extends keyof I>(name: K): Lens<I, I[K]> {
        const projector = (i: I): I[K] => i[name]
        const updater = (i: I, newVal: I[K]) => {
            if (typeof i[name] !== typeof newVal) {
                return i
            }
            const iCopy = Object.create(i) as I;
            Object.assign(iCopy, i)
            const newProp = {} as Partial<I>;
            newProp[name] = newVal
            Object.assign(iCopy, newProp)
            return iCopy
        }
        return new GenericLens(projector, updater)
    }
}

When K is the type of names, then P is the mapped array/tuple type { [N in keyof K]: I[K[N]] }, since you are performing the indexed access I[K[N]] for every numeric index N of the K array (that is, K[N] is Nth element of K, which is itself a key of I). So that makes forProperties() look like this:

static forProperties<I extends object>() {
    return function <K extends Array<keyof I>>(names: [...K]): Lens<I, { [N in keyof K]: I[K[N]] }> {
        const projector = (i: I) => {
            const tuple = [] as Array<I[K[number]]> as { [N in keyof K]: I[K[N]] };
            for (const name of names) {
                tuple.push(i[name])
            }
            return tuple
        }
        const updater = (i: I, newData: { [N in keyof K]: I[K[N]] }) => {
            const iCopy = Object.create(i) as I;
            Object.assign(iCopy, i)
            let it = 0
            for (const name of names) {
                const newProp = {} as Partial<I>;
                newProp[name] = newData[it]
                Object.assign(iCopy, newProp)
                it  
            }
            return iCopy
        }
        return new GenericLens(projector, updater)
    }
}

Now we can test it out:

const postalAddressCityLens = GenericLens.forProperty<PostalAddress>()("city");
// const postalAddressCityLens: Lens<PostalAddress, string>

const postalAddressCountryNumberBooleanLens = GenericLens.forProperties<PostalAddress>()(
    ["country", "someNumber", "someBool"]
);
// const postalAddressCountryNumberBooleanLens: Lens<PostalAddress, [string, number, boolean]>

You see how we have to throw that extra function call in there, just two little extra parentheses, as payment for the ability to emulate partial inference. You specify PostalAddress and the compiler infers the rest!

Playground link to code Code here https://tsplay.dev/W4nq4N

  • Related