Búsqueda de sitios web

typescript - Validando si el valor de retorno cumple con el Tipo Condicional en la firma de la función


Estoy intentando aplicar el concepto de "Programación Orientada al Ferrocarril" de Scott Wlaschin en Typescript.

Estoy tratando de obtener los tipos correctos para la función either. Creo que mi código a continuación debería funcionar, pero estoy obteniendo errores del compilador en las sentencias de retorno de la función either.

type Success<T> = {
    success: true,
    result: T
};

type Failure<E> = {
    success: false,
    cause: E
}

export function either<T, E, U, F, I extends Success<T> | Failure<E>>(
    onSuccess: (i: T) => U,
    onFailure: (i: E) => F,
    input: I
  ): I extends Success<T> ? U : I extends Failure<E> ? F : never {
    if (input.success === true) {
      return onSuccess(input.result); // compiler error: Type 'U' is not assignable to type 'I extends Success<T> ? U : I extends Failure<E> ? F : never'
    } else {
      return onFailure(input.cause); // compiler error: Type 'F' is not assignable to type 'I extends Success<T> ? U : I extends Failure<E> ? F : never'
    }
  }

No estoy seguro de la causa de este error, ya que el tipo condicional indica que se pueden devolver U y F, y mi código produce la correcta reducción de tipo del tipo del argumento de entrada. Puedo eliminarlos usando una Aserción de Tipo que coincida con el tipo de retorno de la función (por ejemplo, return onSuccess(input.result) as I extends Success<T> ? ....), pero mis preguntas son:

  1. ¿Es esta la única forma de deshacerse de los errores del compilador?
  2. Si es así, ¿por qué TypeScript no puede determinar que los valores de retorno cumplen con el tipo de retorno especificado?

Respuesta #1

Actualmente, el análisis de flujo de control de TypeScript no afecta a los parámetros de tipo genéricos. Si tienes un valor t de un tipo genérico T, entonces un switch/case o if/else o ?/: que proteja el tipo de t puede reducir el tipo aparente de t de T a otra cosa (por ejemplo, T & string), pero no puede hacer nada con el parámetro de tipo T.

Esto es por una buena razón: no puedes simplemente volver a restringir T. Supongamos que T extends A | B, y verificas isA(t), lo que estrecha t a A (o T & A). Esto no implica que T extends A. T podría ser A, o podría ser el tipo de unión completo A | B. Hemos establecido un nuevo límite inferior para T, no un nuevo límite superior. Tal vez eso se escribiría como T super A extends A | B, si TypeScript tuviera una forma de expresar restricciones de límite inferior, como se solicitó en microsoft/TypeScript#14520. Pero no la tiene, así que estamos un poco atrapados, hasta que eso se implemente, si es que alguna vez lo hace.

Podrías estar pensando que la intención de T extends A | B es que T debería ser exactamente uno de A o B, y que las uniones de esas posibilidades deberían excluirse de consideración desde el principio. Eso sería agradable, pero no es lo que significa extends. Necesitaríamos un nuevo tipo de restricción para eso, como se solicitó en microsoft/TypeScript#27808. Llamémoslo oneof. Entonces podrías escribir T oneof A | B y verificar isA(t) de hecho volvería a restringir T a T oneof A. Pero nuevamente, esto no es posible actualmente, así que tenemos que esperar o encontrar una solución alternativa.

Con mucho, la solución más fácil es simplemente usar aserciones de tipo para afirmar que isA(t) realmente implica que T es A.


Pero preguntaste "¿es esta la única manera de deshacerse de los errores del compilador?" y la respuesta a eso es "no, pero probablemente deberías hacerlo de todos modos".

En este momento, si quieres usar genéricos y tener tipos similares a los dependientes sin afirmaciones de tipo, necesitarías refactorizar para que no use análisis de flujo de control, sino que realice una de las pocas operaciones genéricas que TypeScript puede verificar. Principalmente, esto implica indexar con un tipo de clave genérica en algún tipo de objeto o tipos mapeados en ese tipo de objeto. El enfoque general se describe en microsoft/TypeScript#47109.

Desafortunadamente para tu caso, la refactorización es bastante terrible y obtrusiva. No puedes indexar en objetos con valores boolean, por lo que necesitaríamos cambiar a algún tipo de cadena/número/enum en su lugar:

enum Bool { FALSE, TRUE }
const _true = Bool.TRUE;
const _false = Bool.FALSE;

Y luego necesitarías reescribir tus tipos para que siempre incluyan la indexación en tipos mapeados explícitos sobre objetos con estas claves pseudo-booleanas. Tal vez así:

interface BoolMap<T, F> {
    [_true]: T,
    [_false]: F
}
type Either<T, F, B extends Bool> = { [P in B]:
    { success: P } & BoolMap<{ result: T }, { cause: F }>[P]
}[B]

function either<T, F, TO, FO, B extends Bool>(
    onSuccess: (i: T) => TO,
    onFailure: (i: F) => FO,
    input: Either<T, F, B>
) {
    const func: { [P in Bool]:
        (i: Either<T, F, P>) => BoolMap<TO, FO>[P]
    } = {
        [_true]: v => onSuccess(v.result),
        [_false]: v => onFailure(v.cause)
    };
    return func[input.success](input);
}

¡Puaj! Pero al menos se compila sin errores y sin afirmaciones de tipo. Probémoslo para asegurarnos de que todavía se comporte como se espera:

const succ = (x: string) => x.length;
const fail = (x: number) => x.toFixed(2);

const r1 = either(succ, fail, { success: _true, result: "abc" });
// const r1: number

const r2 = either(succ, fail, { success: _false, cause: 404 });
// const r2: string

const r3 = either(succ, fail,
    Math.random() < 0.5 ?
        { success: _true, result: "abc" } :
        { success: _false, cause: 404 }
);
// const r3: string | number

Se ve bien, así que al menos los que llaman a either tienen la misma experiencia, eh, si ignoras lo del booleano.


Ahí tienes. Personalmente, probablemente me quedaría con las aserciones de tipo y tal vez en alguna versión futura de TypeScript, cuando el análisis del flujo de control afecte a los parámetros de tipo genéricos, y puedas eliminarlas.

Enlace de Playground al código