Search code examples
typescripttypescript-generics

Using TypeScript generics to update attributes of an interface


I'm trying to write a generic function that can be used to update the string attributes of an interface. In short, given the following:

interface SomeObject {
    b1: boolean;
    s1: string;
    s2: string;
    n1: number;
    s3: string;
    n2: number;
}

const obj = getSomeObject();

const stringResult1 = getResultOf("s1");
obj.s1 = stringResult1;

const stringResult2 = getResultOf("s2");
obj.s2 = stringResult2;

const stringResult3 = getResultOf("s3");
obj.s3 = stringResult3;

I would like to do the following:

interface SomeObject {
    b1: boolean;
    s1: string;
    s2: string;
    n1: number;
    s3: string;
    n2: number;
}

const obj = getSomeObject();

doSomething(obj, "s1");
doSomething(obj, "s2");
doSomething(obj, "s3");

// This should cause a compile error because "n1" is a number.
doSomething(obj, "n1");

// This should cause a compile error because "cx" doesn't exist.
doSomething(obj, "cx");

I've got as far as sorting out the parameter type so that I can restrict the property selection, but the function implementation code is giving the dreaded TS2322 error.

This is as far as I've been able to get so far:

type StringTypeOf<T> = {
    [P in keyof T as T[P] extends (string | undefined) ? P : never]: string
}

type StringKeyOf<T> = keyof StringTypeOf<T>;

function doSomething<T>(t: T, key: StringKeyOf<T>) {
    // The dreaded TS2322 error.
    // Type 'string' is not assignable to type 'T[keyof { [P in keyof T as T[P] extends string | undefined ? P : never]: string; }]'.
    //   'T[keyof { [P in keyof T as T[P] extends string | undefined ? P : never]: string; }]' could be instantiated with an arbitrary type which could be unrelated to 'string'.
    t[key] = "123";
}

interface Whatever {
    a: string;
    b?: string;
    readonly c: string;
    d: number;
    e?: boolean;
};

const w: Whatever = {
    a: "this",
    b: "is",
    c: "frustrating",
    d: 0
}

// This works, as attribute 'a' is type string.
doSomething(w, "a");

// This works, as attribute 'b' is type string or undefined.
doSomething(w, "b");

// This works, as attribute 'c' is type string, but it would be nice to restrict it because 'c' is readonly.
doSomething(w, "c");

// This fails, as attribute 'd' is type number.
doSomething(w, "d");

// This fails, as attribute 'e' is type boolean or undefined.
doSomething(w, "e");

// This fails, as attribute 'f' doesn't exist.
doSomething(w, "f");

The code is available on TypeScript Playground to show the various errors.

Ideally, the declaration should:

  1. allow me to limit the parameter type to those that are string attributes (done);
  2. forbid me from selecting read-only string attributes; and
  3. allow me to assign a new value to the attribute within the function.

Thanks in advance.

--- EDIT ---

Thanks to the comment from jcalz and the answer from Alexander Nenashev, I've done a "separation of concerns" and come up with what I think is a workable solution here.


Solution

  • You can use an extra logic to filter readonly keys out and use an assertion to cast a string literal:

    Playground

    type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;
    
    type StringTypeOf<T> = {
        [P in keyof T as T[P] extends (string | undefined) ? Equal<Pick<T, P> , Readonly<Pick<T, P>>> extends true ? never : P : never]: string
    }
    
    type StringKeyOf<T> = keyof StringTypeOf<T>;
    
    function doSomething<T, K extends StringKeyOf<T>>(t: T, key: K) {
        t[key] = 'K' as K;
    }