Búsqueda de sitios web

typescript - ¿Cómo tipar correctamente una función genérica similar a Object.assign?


Estoy tratando de crear una función de "asignar por defecto" en TypeScript, donde itera a través de las claves de source, y si ese valor por la misma clave es nulo o indefinido en target, utilizará el valor de source en su lugar. Aquí está mi intento:

const assignDefault = <T, U>(target: T, source: U): T & U => {
  Object.keys(source).forEach(key => {
    // typecasted as Object.keys returns string[]
    const prop = target[key as keyof T]
    if (typeof prop === 'undefined' || prop === null) {
      // Error: Type 'U[keyof U]' is not assignable to type 'T[keyof T]'.
      target[key as keyof T] = source[key as keyof U] 
    }
  })
  return target // Error: Type 'T' is not assignable to type 'T & U'.
}

Tomé prestados los genéricos de cómo Object.assign está tipado en TypeScript:

ObjectConstructor.assign<T, U>(target: T, source: U): T & U;

Pero no pude encontrar una manera de evitar estos errores.

Parque infantil

Respuesta #1

Tomé los genéricos de cómo está tipado Object.assign

Hay una gran diferencia entre las definiciones de tipo y la implementación. Al compilador le gusta : T & U de la biblioteca estándar porque no hay información conflictiva. En tu implementación, sin embargo, el tipo de target es solo T, mientras que la firma espera T & U, de ahí el error del compilador.

El compilador no es un entorno de ejecución, así que aunque realiza cierto análisis de flujo de control, no puede inferir que forEach muta target. Sin embargo, existen formas de hacer que el compilador sea consciente de eso. La más sencilla es afirmar el tipo del valor returnado, ya sea como any (para desactivar el verificador de tipos para él) o T & U:

const assignDefault = <T, U>(target: T, source: U): T & U => {
    Object.keys(source).forEach((key) => {
        const prop = target[key as keyof T]
        if (typeof prop === 'undefined' || prop === null) {
          // Error: Type 'U[keyof U]' is not assignable to type 'T[keyof T]'.
          target[key as keyof T] = source[key as keyof U] 
        }
    });
    return target as T & U;
};

Parque infantil

Sin embargo, hay otro problema con el tipo de retorno de assignDefault: T & U es una intersección, lo cual no es lo que deseas. Para tipar correctamente "si es nulo o indefinido, asignar", se necesita un tipo mapeado con algunos tipos condicionales y comprobaciones de extends para determinar si se puede asignar una propiedad.

Un tipo así podría verse algo así:

type AssignIfNullish<T, U> = {
    [P in keyof T]:  
        T[P] extends null | undefined ? // is value a subtype of null|undefined?
        U[P & keyof U]: // take the value from U
        T[P] // preserve the original
};

// type Test = { a: "null"; b: "undefined"; c: 42; }
type Test = AssignIfNullish<
    { a: null,  b: undefined,  c: 42 }, 
    { a:"null", b:"undefined", c: 24 }
>;

Parque infantil

El resto es solo una cuestión de afirmar el tipo de retorno de la función:

const assignDefault = <T, U>(target: T, source: U) => {
    Object.keys(source).forEach((key) => {
        const prop = target[key as keyof T]
        if (typeof prop === 'undefined' || prop === null) {
          // Error: Type 'U[keyof U]' is not assignable to type 'T[keyof T]'.
          target[key as keyof T] = source[key as keyof U] 
        }
    });
    return target as AssignIfNullish<T, U>;
};

assignDefault({ a:1,b:null } as const,{ b:42 } as const); // { a:1, b:42 }

Parque infantil

Finalmente, hay un error más que abordar:

Error: El tipo 'U[keyof U]' no se puede asignar al tipo 'T[keyof T]'.

El compilador verifica si U[keyof U] (source[key]) se puede asignar a T[keyof T] (target[key]), pero no tiene ninguna información sobre cómo T y U están relacionados. Todo lo que sabe es que ambos son parámetros de tipo genérico y, por lo tanto, pueden ser cualquier cosa. Según la firma, ni siquiera está garantizado que alguno de los dos sea un objeto (tú lo sabes, pero no el compilador):

assignDefault(true, false); // no objection from the compiler

Dado que en este caso sabes más que el compilador, está bien usar as unknown as T[keyof T] para afirmar que source[key] es en realidad T[keyof T]:

const assignDefault = <
    T extends object,
    U extends object
>(target: T, source: U) => {
    Object.keys(source).forEach((key) => {
        const prop = target[key as keyof T]
        if (typeof prop === 'undefined' || prop === null) {
            target[key as keyof T] = source[key as keyof U] as unknown as T[keyof T];
        }
    });
    return target as AssignIfNullish<T, U>;
};

assignDefault(true, false); // error as expected
assignDefault({ a:null }, { a:42, b: "extra" }); // ok, { a:number }

Parque infantil

Eso son muchas afirmaciones, sin embargo, ¿podemos hacerlo mejor? Sí, si renunciamos a Object.keys en favor del buen y viejo bucle for...in debido a la peculiaridad de que key se tipa como string (hay una buena razón para ello, pero aun así). Usar for...in (con la guardia apropiada) nos permite eliminar las afirmaciones de as keyof:

const assignDefault = <
    T extends Partial<{ [P in keyof U]: unknown }> & object,
    U extends Partial<{ [P in keyof T]: unknown }> & object
>(target: T, source: U) => {
    for (const key in source) {
        if (!Object.prototype.hasOwnProperty.call(source, key)) continue;

        const prop = target[key];

        if (typeof prop === 'undefined' || prop === null) {
            Object.assign(target, key, { [key]: source[key] });
        }
    }

    return target as AssignIfNullish<T, U>;
};

Parque infantil

Observe el uso de Object.assign(target, key, { [key]: source[key] }); para evitar el error de asignabilidad (también se podría usar Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)!);).

Respuesta #2

Esto es lo que se me ocurrió.

Un par de cosas para notar:

typeof nunca devolverá "null" vea: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/typeof

segundo, el tipo T & U espera una combinación tanto del objetivo como de la fuente. Puedes lograr eso bastante fácilmente usando el operador spread para fusionarlos como se muestra en mi ejemplo.

[EDITAR] En lugar de estar restringido por el tipo que requerías, modifiqué completamente la función para que se ajuste más a tu intención original.

const assignDefault = (target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> => {
    for (const key in source) {
        if (target[key] === null || typeof target[key] === 'undefined') {
            target[key] = source[key];
        }
    }
    return target;
}

const myTarget = {
    prop1: null,
    prop2: "some value",
    prop3: ["some", "other", "value"]
}

const mySource = {
    prop1: "hello world",
    prop2: "don't show this value",
    prop3: [],
    prop4: "a value not provided by target originally"
}

// expect
/**
 * {
 *  prop1: "hello world",
 *  prop2: "some value",
 *  prop3: ["some", "other", "value"],
 *  prop4: "a value not provided by target originally"
 * }
 */

const newObj = assignDefault(myTarget, mySource);

console.log(newObj);

Tenga en cuenta que esta función solo funciona realmente con objetos que tienen pares clave-valor.

Artículos relacionados