Búsqueda de sitios web

Asegurando que las uniones de TypeScript permanezcan estrechas a lo largo del código


Tengo un flujo, en el cual, en lugar de agregar una cantidad de declaraciones if, me gustaría crear un objeto que contenga la información requerida para que, cuando llegue la ejecución, se lea de una manera más lineal. He creado un ejemplo simple y artificial de esto a continuación.

type ItemOne = {
  type: 'itemOne'
  fn: typeof funcOne
  args: Parameters<typeof funcOne>[0]
}

type ItemTwo = {
  type: 'itemTwo',
  fn: typeof funcTwo
  args: Parameters<typeof funcTwo>[0]
}

type Item = ItemOne | ItemTwo


function funcOne({ name }: { name: string }) {
  console.log(name)
}

function funcTwo({ age }: { age: number }) {
  console.log(age)
}

function createItem(shouldCreateItemOne: boolean): Item {
  if (shouldCreateItemOne) {
    return { type: 'itemOne', fn: funcOne, args: { name: 'Dave' } } as const
  }

  return { type: 'itemTwo', fn: funcTwo, args: { age: 22  } } as const
}


const item = createItem(false) 

item.fn(item.args)

Cuando llego a llamar a la función item.fn(item.args), TypeScript se queja Argument of type '{ name: string; } | { age: number; }' is not assignable to parameter of type '{ name: string; } & { age: number; }'. ¿Hay alguna manera de asegurar que TypeScript pueda mantener una versión restringida de item.args?

También hay un enlace al ejemplo dentro del playground de TypeScript

Respuesta #1

Esto parece un trabajo para sobrecargas, donde quieres que el tipo de retorno cambie según los tipos de los parámetros dados:

function createItem(shouldCreateItemOne: true): ItemOne;
function createItem(shouldCreateItemOne: false): ItemTwo;
function createItem(shouldCreateItemOne: boolean): Item;
function createItem(shouldCreateItemOne: boolean): Item {
  if (shouldCreateItemOne) {
    return { type: 'itemOne', fn: funcOne, args: { name: 'Dave' } } as const
  }

  return { type: 'itemTwo', fn: funcTwo, args: { age: 22  } } as const
}

La última sobrecarga es opcional si no deseas soportar llamadas a la función con el tipo general boolean. Usar una sobrecarga permite que TypeScript deduzca el tipo correcto para esta llamada:

const item = createItem(false)
//    ^? ItemTwo
item.fn(item.args) // okay

Parque infantil


Si tienes más de dos elementos y necesitas más flexibilidad, entonces esto puede no ser lo mejor para ti. Consulta el comentario de @jcalz para una alternativa usando acceso indexado genérico.


También puedes reorganizar tus tipos de Item en un mapa:

interface ItemMap {
  itemOne: typeof funcOne;
  itemTwo: typeof funcTwo;
}

type Item<K extends keyof ItemMap = keyof ItemMap> = {
  [P in K]: { type: P; fn: ItemMap[P]; args: Parameters<ItemMap[P]>[0] };
}[K];

con la declaración createItem ahora luciendo así

function createItem(shouldCreateItemOne: true): Item<"itemOne">;
function createItem(shouldCreateItemOne: false): Item<"itemTwo">;
function createItem(shouldCreateItemOne: boolean): Item;
function createItem(shouldCreateItemOne: boolean): Item {

Parque infantil

Respuesta #2

TypeScript no sabe cómo manejar los "tipos de unión correlacionados" como se describe en microsoft/TypeScript#30581. Es el caso que si tienes un valor item de tipo Item, deberías poder llamar a item.fn(item.args). Pero el compilador no puede ver eso porque el sistema de tipos no puede representar la correlación entre item.fn y item.args de forma abstracta. Si puede reducir item a ItemOne o ItemTwo, estaría bien:

declare const item: Item;
if (item.type === "itemOne") {
  item.fn(item.args);
} else {
  item.fn(item.args);
}

pero con solo Item, el tipo de item.args es la unión {name: string} | {age: number} mientras que el tipo de item.fn es la unión ((a: {name: string}) => void) | ((a: {age: number}) => void), y en términos generales no es seguro llamar a una unión de funciones con una unión de parámetros... ¿qué pasaría si hubiera itemA y itemB ambos de tipo Item? No deberías llamar a itemA.fn(itemB.args), ¿verdad? Bueno, desafortunadamente, el compilador no puede diferenciar eso de lo que estás tratando de hacer.


El enfoque recomendado para tratar con uniones correlacionadas se describe en microsoft/TypeScript#47109. Involucra una refactorización alejándose de las uniones y hacia los genéricos, donde las operaciones se expresan como accesos indexados genéricos en tipos mapeados.

Primero deberías crear un tipo de objeto de "mapeo" simple del cual construyas los tipos Item más complicados:

interface ItemMap {
  itemOne: { name: string };
  itemTwo: { age: number }
}

Ahora podemos hacer que Item sea un tipo de objeto distributivo genérico (como se acuñó en ms/TS#47109) de la siguiente manera:

type Item<K extends keyof ItemMap = keyof ItemMap> =
  { [P in K]: {
    type: P,
    fn: (arg: ItemMap[P]) => void;
    args: ItemMap[P]
  } }[K];

Puedes recuperar tus tipos ItemOne y ItemTwo si quieres:

type ItemOne = Item<"itemOne">
type ItemTwo = Item<"itemTwo">;

y el tipo Item por sí solo es equivalente a ItemOne | ItemTwo debido al argumento de tipo genérico predeterminado en la definición.

En cierto sentido no hemos cambiado nada sobre tus tipos, pero la representación como índices en un tipo mapeado ayuda al compilador a seguir las cosas cuando escribimos una operación genérica:

function processItem<K extends keyof ItemMap>(item: Item<K>) {
  const fn = item.fn; 
  // const fn: (arg: ItemMap[K]) => void
  const arg = item.args;
  // const arg: ItemMap[K]
  fn(arg); // okay
}

o solo

function processItem<K extends keyof ItemMap>(item: Item<K>) {
  item.fn(item.args); // okay
}

Puedes ver que item.args es de tipo genérico ItemMap[K], mientras que item.fn es de tipo genérico (arg: ItemMap[K]) => void, lo que significa que el compilador definitivamente ve que item.args es un argumento válido para la función item.fn.


Y probémoslo:

function createItem(shouldCreateItemOne: boolean): Item {
  if (shouldCreateItemOne) {
    return { type: 'itemOne', fn: funcOne, args: { name: 'Dave' } } as const
  }
  return { type: 'itemTwo', fn: funcTwo, args: { age: 22 } } as const
}

Esto todavía funciona porque el compilador ve que el valor de retorno es un Item válido. Y cuando lo llamamos, obtenemos un Item (que es Item<keyof ItemMap> que es ItemOne | ItemTwo):

const item = createItem(false);

y finalmente, podemos procesar el elemento con nuestra función de procesamiento:

processItem(item); // okay
// function processItem<keyof ItemMap>(item: Item<keyof ItemMap>): void

Tenga en cuenta que el argumento de tipo K se infiere como keyof ItemMap porque item es un Item<keyof ItemMap>. Esto aún funcionaría incluso si item fuera simplemente un ItemOne o un ItemTwo.

Enlace de Playground al código