Búsqueda de sitios web

Combinación de Tipos en TypeScript


Tengo un caso en el que quiero "fusionar" tipos donde la unión de tipos por defecto (es decir, T | U o T & U) no logra lo que quiero.

Lo que estoy tratando de hacer es una fusión profunda e inteligente que marcará automáticamente las propiedades como opcionales durante la fusión y realizará fusiones profundas de interfaces/tipos de TypeScript.

Para dar un ejemplo, supongamos que tenemos los tipos A y B.

type A = {
  a: string;
  b: number;
  c: boolean;
  d: {
    a2: string;
    b2: number;
  };
  e?: number;
};

type B = {
  a: string;
  b: boolean;
  d: {
    a2: string;
    c2: boolean;
  };
};

Estoy buscando una función Merge que acepte 2 tipos genéricos

type Merge<T, U> = ?????;

Entonces, si se usa en los tipos A y B, la salida sería la siguiente

type AB = {
  a: string;
  b: number | boolean;
  c?: boolean;
  d: {
    a2: string;
    b2?: number;
    c2?: boolean;
  };
  e?: number;
};

Como esto muestra, el tipo Merge realizaría la siguiente lógica:

  1. Si la propiedad existe tanto en T como en U y es de un tipo idéntico, márcala como obligatoria y establece que sea del tipo en ambos T/U (como ocurrió con la propiedad a).
  2. Si la propiedad existe tanto en T como en U pero es de un tipo diferente, márcala como requerida y establece un tipo unión si es un primitivo (como ocurrió con la propiedad b) o realiza una fusión recursiva si es un objeto (como ocurrió con la propiedad d).
  3. Si la propiedad existe en un tipo pero no en otro, marque la propiedad como opcional y asígnela al tipo en el que realmente existía en el tipo de entrada (como ocurrió con la propiedad c, así como con b2 y c2).
  4. Si la propiedad ya es opcional en un tipo, debería ser opcional en el tipo de salida con las reglas existentes anteriores aplicadas para determinar su valor (como ocurrió con la propiedad e)

Supón que puedes usar tipos condicionales recursivos, aunque reconozco que aún no están oficialmente soportados y no deberían usarse en producción. Puedo hacer una versión desenrollada similar a la solución de jcalz@ aquí para casos de uso en producción.

Aquí hay un área de juegos preparada para probar la pregunta.

Respuesta #1

TLDR: ¡Magia! Prueba el Playground

Entonces, esta es una pregunta complicada. No tanto por los requisitos de fusión, sino por los casos límite. Conseguir los frutos fáciles tomó menos de 20 minutos. Asegurarse de que funcione en todas partes tomó un par de horas más... y triplicó la duración. ¡Las uniones son complicadas!

  1. ¿Qué es una propiedad opcional? En { a: 1 | undefined, b?: 1 }, ¿es a una propiedad opcional? Algunas personas dicen que sí. Otros no. Personalmente, solo incluyo b en la lista de opcionales.

  2. ¿Cómo manejas las uniones? ¿Cuál es la salida de Merge<{}, { a: 1} | { b: 2 }>? Creo que el tipo que tiene más sentido es { a?: 1 } | { b?: 2 }. ¿Qué hay de Merge<string, { a: 1 }>? Si no te importan las uniones, esto es fácil... si te importan, entonces tienes que considerar todo esto. (Lo que elegí entre paréntesis)

    1. Merge<never, never> (nunca)
    2. Merge<never, { a: 1 }> ({ a?: 1 })
    3. Merge<string, { a: 1 }> (string | { a?: 1 })
    4. Merge<string | { a: 1 }, { a: 2 }> (string | { a: 1 | 2 })

Vamos a averiguar este tipo, comenzando con los ayudantes.

Tuve una corazonada en cuanto pensé en uniones de que este tipo iba a volverse complejo. TypeScript no tiene una forma incorporada agradable para probar la igualdad de tipos, pero podemos escribir un tipo auxiliar que cause un error de compilador si dos tipos no son iguales.

(Nota: El tipo Test podría mejorarse, podría permitir que pasen tipos que no son equivalentes, pero es suficiente para nuestros usos aquí y sigue siendo bastante simple)

type Pass = 'pass';
type Test<T, U> = [T] extends [U]
    ? [U] extends [T]
        ? Pass
        : { actual: T; expected: U }
    : { actual: T; expected: U };

function typeAssert<T extends Pass>() {}

Podemos usar este ayudante de esta manera:

// try changing Partial to Required
typeAssert<Test<Partial<{ a: 1 }>, { a?: 1 }>>();

A continuación, necesitaremos dos tipos auxiliares. Uno para obtener todas las claves obligatorias de un objeto, y otro para obtener las claves opcionales. Primero, algunas pruebas para describir lo que queremos:

typeAssert<Test<RequiredKeys<never>, never>>();
typeAssert<Test<RequiredKeys<{}>, never>>();
typeAssert<Test<RequiredKeys<{ a: 1; b: 1 | undefined }>, 'a' | 'b'>>();

typeAssert<Test<OptionalKeys<never>, never>>();
typeAssert<Test<OptionalKeys<{}>, never>>();
typeAssert<Test<OptionalKeys<{ a?: 1; b: 1, c: undefined }>, 'a'>>();

Hay dos cosas a tener en cuenta aquí. Primero, *Keys<never> es never. Esto es importante porque usaremos estos auxiliares en uniones más adelante, y si el objeto es never no debería aportar ninguna clave. Segundo, ninguna de estas pruebas incluye verificaciones de uniones. Considerando lo importante que mencioné que eran las uniones, esto podría sorprenderte. Sin embargo, estos tipos solo se usan después de que todas las uniones se distribuyen, por lo que su comportamiento allí no importa (aunque si incluyes esto en tu proyecto, podrías querer observar dicho comportamiento, es diferente de lo que probablemente esperarías para RequiredKeys debido a cómo está escrito)

Estos tipos pasan las verificaciones dadas:

type OptionalKeys<T> = {
    [K in keyof T]-?: T extends Record<K, T[K]> ? never : K;
}[keyof T;

type RequiredKeys<T> = {
    [K in keyof T]-?: T extends Record<K, T[K]> ? K : never;
}[keyof T] & keyof T;

Un par de notas sobre estos:

  1. Usa -? para eliminar la opcionalidad de las propiedades, esto nos permite evitar un contenedor de Exclude<..., undefined>
  2. T extends Record<K, T[K]> funciona porque { a?: 1 } no extiende { a: 1 | undefined }. Pasé por varias iteraciones antes de finalmente decidirme por esto. También puedes detectar la opcionalidad con otro tipo mapeado como hace jcalz aquí.
  3. En la versión 3.8.3, TypeScript puede inferir correctamente que el tipo de retorno de OptionalKeys es asignable a keyof T. Sin embargo, no puede detectar lo mismo para RequiredKeys. Intersectar con keyof T soluciona esto.

Ahora que tenemos estos ayudantes, podemos definir dos tipos más que representan la lógica de tu negocio. Necesitamos RequiredMergeKeys<T, U> y OptionalMergeKeys<T, U>.

type RequiredMergeKeys<T, U> = RequiredKeys<T> & RequiredKeys<U>;

type OptionalMergeKeys<T, U> =
    | OptionalKeys<T>
    | OptionalKeys<U>
    | Exclude<RequiredKeys<T>, RequiredKeys<U>>
    | Exclude<RequiredKeys<U>, RequiredKeys<T>>;

Y algunas pruebas para asegurarse de que estos se comporten como se espera:

typeAssert<Test<OptionalMergeKeys<never, {}>, never>>();
typeAssert<Test<OptionalMergeKeys<never, { a: 1 }>, 'a'>>();
typeAssert<Test<OptionalMergeKeys<never, { a?: 1 }>, 'a'>>();
typeAssert<Test<OptionalMergeKeys<{}, {}>, never>>();
typeAssert<Test<OptionalMergeKeys<{ a: 1 }, { b: 2 }>, 'a' | 'b'>>();
typeAssert<Test<OptionalMergeKeys<{}, { a?: 1 }>, 'a'>>();

typeAssert<Test<RequiredMergeKeys<never, never>, never>>();
typeAssert<Test<RequiredMergeKeys<never, {}>, never>>();
typeAssert<Test<RequiredMergeKeys<never, { a: 1 }>, never>>();
typeAssert<Test<RequiredMergeKeys<{ a: 0 }, { a: 1 }>, 'a'>>();

Ahora que tenemos esto, podemos definir la fusión de dos objetos, ignorando los primitivas y las uniones por el momento. Esto llama al tipo de nivel superior Merge que aún no hemos definido para manejar los primitivas y las uniones de los miembros.

type MergeNonUnionObjects<T, U> = {
    [K in RequiredMergeKeys<T, U>]: Merge<T[K], U[K]>;
} & {
    [K in OptionalMergeKeys<T, U>]?: K extends keyof T
        ? K extends keyof U
            ? Merge<Exclude<T[K], undefined>, Exclude<U[K], undefined>>
            : T[K]
        : K extends keyof U
        ? U[K]
        : never;
};

(No escribí pruebas específicas aquí porque las tenía para el siguiente nivel)

Necesitamos manejar tanto uniones como no-objetos. Vamos a manejar uniones de objetos a continuación. Según la discusión anterior, necesitamos distribuirnos sobre todos los tipos y fusionarlos individualmente. Esto es bastante sencillo.

type MergeObjects<T, U> = [T] extends [never]
    ? U extends any
        ? MergeNonUnionObjects<T, U>
        : never
    : [U] extends [never]
    ? T extends any
        ? MergeNonUnionObjects<T, U>
        : never
    : T extends any
    ? U extends any
        ? MergeNonUnionObjects<T, U>
        : never
    : never;

Tenga en cuenta que tenemos verificaciones adicionales para [T] extends [never] y [U] extends [never]. Esto se debe a que never en una cláusula distributiva es como for (let i=0; i < 0; i++), nunca entrará en el "cuerpo" del condicional y, por lo tanto, retornará never, pero solo queremos never si ambos tipos son never.

¡Ya casi llegamos! Ahora podemos manejar la combinación de objetos, que es la parte más difícil de este problema. Todo lo que queda es manejar los primitivos, lo cual podemos hacer simplemente formando una unión de todos los primitivos posibles y excluyendo los primitivos de los tipos pasados a MergeObjects.

type Primitive = string | number | boolean | bigint | symbol | null | undefined;

type Merge<T, U> =
    | Extract<T | U, Primitive>
    | MergeObjects<Exclude<T, Primitive>, Exclude<U, Primitive>>;

¡Y con ese tipo, hemos terminado! Merge se comporta como se desea, en solo unas 50 líneas de locura sin comentarios.

... ¿o lo estamos? @petroni mencionó en los comentarios que este tipo no funciona bien con arreglos que están presentes en ambos objetos. Hay varias formas de manejar esto, particularmente porque los tipos de tupla de TypeScript se han vuelto cada vez más flexibles. Fusionar correctamente [1, 2] y [3] probablemente debería dar [1 | 3, 2?]... pero hacer eso es al menos tan complicado como lo que ya hemos hecho. Una solución mucho más simple es ignorar las tuplas por completo y siempre producir un arreglo, así que este ejemplo produciría (1 | 2 | 3)[].

Una nota final sobre los tipos producidos:

El tipo resultante de Merge en este momento es correcto, pero no es tan legible como podría ser. Actualmente, al pasar el cursor sobre el tipo resultante, se mostrará una intersección y los objetos internos tendrán Merge envuelto alrededor de ellos en lugar de mostrar el resultado. Podemos solucionar esto introduciendo un tipo Expand que obligue a TS a expandir todo en un solo objeto.

type Expand<T> = T extends Primitive ? T : { [K in keyof T]: T[K] };

Ahora solo modifica MergeNonUnionObjects para llamar a Expand. Dónde es necesario esto es en cierto modo prueba y error. Puedes experimentar incluyendo esto, o no, para obtener una visualización de tipos que funcione para ti.

type MergeNonUnionObjects<T, U> = Expand<
    {
        [K in RequiredMergeKeys<T, U>]: Expand<Merge<T[K], U[K]>>;
    } & {
        [K in OptionalMergeKeys<T, U>]?: K extends keyof T
            ? K extends keyof U
                ? Expand<Merge<
                    Exclude<T[K], undefined>,
                    Exclude<U[K], undefined>
                >>
                : T[K]
            : K extends keyof U
            ? U[K]
            : never;
    }
>;

Revísalo en el área de pruebas que incluye todas las pruebas que usé para validar los resultados.

Artículos relacionados