Como fazer sua função pipe ser fortemente tipada? Os conceitos por trás de tipos recursivos e cadeias de função
Após a descoberta do reduce tipado, eu comecei a criar alguns desafios de tipo para poder ver até onde essa implementação resolve problemas do Typescript. Dessa vez me arrisquei a utilizar essa implementação para resolver o problema da função pipe.
Too long; didn't read
Caso você só queira somente olhar o resultado, você pode observar abaixo. Não se esqueça de instalar a dependência ts-toolbelt. Mas vale avisar que existe um limite para a tipagem desse modo, já que não é possível fazer uma extensa inferência. Isso é comentado nas implementações abaixo sobre o ramda e o lodash. Esse resultado é um resultado um pouco genérico e pode apresentar problemas em algumas implementações. Caso você queira uma solução um pouco mais robusta, você pode seguir com a leitura.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
import { L, N } from "ts-toolbelt"; type Fn = (a: any[]) => any; type Func = (...a: any[]) => any type PipeArgs<Fns extends readonly Fn[], Func extends Fn, Acc extends readonly Fn[] = [], C extends number = 0> = Fns["length"] extends C ? Acc : PipeArgs<Fns, Fns[C], L.Merge<Acc, [(p: ReturnType<Func>) => ReturnType<Fns[C]>]>, N.Add<C, 1>>; type PipeReturn<First extends Fn, Last extends Fn> = (...params: Parameters<First>) => ReturnType<Last>; export const pipe = <A extends Func, T extends readonly Fn[]>(a: A, ...fns: PipeArgs<T, A>): PipeReturn<A, L.Last<T>> => (fns as Fn[]).reduce( (f: Fn, g: Fn) => (args: any) => g(f(args)), (...args: unknown[]) => a(...args)) as any;
Como dito no artigo de programação funcional, esse artigo visa explicar melhor a tipagem da função pipe
e o porque dela ser tão complicada de tipar.
Antes de apresentar o código (que já está no tl;dr), vamos ver algumas implementações da função pipe
Ramda é uma biblioteca que busca trazer uma forma mais funcional para o Javascript, e depois para o Typescript. Olhando no repositório que contém os @types
, podemos ver a seguinte implementação da função pipe
do ramda. Caso você prefira, pode olhar a implementação direto no GitHub.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
export function pipe<TArgs extends any[], R1, R2, R3, R4, R5, R6, R7, TResult>( ...funcs: [ f1: (...args: TArgs) => R1, f2: (a: R1) => R2, f3: (a: R2) => R3, f4: (a: R3) => R4, f5: (a: R4) => R5, f6: (a: R5) => R6, f7: (a: R6) => R7, ...func: Array<(a: any) => any>, fnLast: (a: any) => TResult, ] ): (...args: TArgs) => TResult; // fallback overload if number of piped functions greater than 7 export function pipe<TArgs extends any[], R1, R2, R3, R4, R5, R6, R7>( f1: (...args: TArgs) => R1, f2: (a: R1) => R2, f3: (a: R2) => R3, f4: (a: R3) => R4, f5: (a: R4) => R5, f6: (a: R5) => R6, f7: (a: R6) => R7, ): (...args: TArgs) => R7; export function pipe<TArgs extends any[], R1, R2, R3, R4, R5, R6>( f1: (...args: TArgs) => R1, f2: (a: R1) => R2, f3: (a: R2) => R3, f4: (a: R3) => R4, f5: (a: R4) => R5, f6: (a: R5) => R6, ): (...args: TArgs) => R6; export function pipe<TArgs extends any[], R1, R2, R3, R4, R5>( f1: (...args: TArgs) => R1, f2: (a: R1) => R2, f3: (a: R2) => R3, f4: (a: R3) => R4, f5: (a: R4) => R5, ): (...args: TArgs) => R5; export function pipe<TArgs extends any[], R1, R2, R3, R4>( f1: (...args: TArgs) => R1, f2: (a: R1) => R2, f3: (a: R2) => R3, f4: (a: R3) => R4, ): (...args: TArgs) => R4; export function pipe<TArgs extends any[], R1, R2, R3>( f1: (...args: TArgs) => R1, f2: (a: R1) => R2, f3: (a: R2) => R3, ): (...args: TArgs) => R3; export function pipe<TArgs extends any[], R1, R2>( f1: (...args: TArgs) => R1, f2: (a: R1) => R2, ): (...args: TArgs) => R2; export function pipe<TArgs extends any[], R1>(f1: (...args: TArgs) => R1): (...args: TArgs) => R1;
É um pouco complicado de entender devido à sobrecarga de método utilizada no código. E é bem interessante lembrar disso porque outra biblioteca bastante famosa, o lodash também faz o uso da mesma técnica.
Como dito antes, aqui está o código do Lodash. E como podemos ver, ambos fazem o uso de sobrecarga de método para resolver o problema da função pipe.
1 2 3 4 5 6 7 8 9 10
interface LodashFlow { <A extends any[], R1, R2, R3, R4, R5, R6, R7>(f1: (...args: A) => R1, f2: (a: R1) => R2, f3: (a: R2) => R3, f4: (a: R3) => R4, f5: (a: R4) => R5, f6: (a: R5) => R6, f7: (a: R6) => R7): (...args: A) => R7; <A extends any[], R1, R2, R3, R4, R5, R6, R7>(f1: (...args: A) => R1, f2: (a: R1) => R2, f3: (a: R2) => R3, f4: (a: R3) => R4, f5: (a: R4) => R5, f6: (a: R5) => R6, f7: (a: R6) => R7, ...func: Array<lodash.Many<(a: any) => any>>): (...args: A) => any; <A extends any[], R1, R2, R3, R4, R5, R6>(f1: (...args: A) => R1, f2: (a: R1) => R2, f3: (a: R2) => R3, f4: (a: R3) => R4, f5: (a: R4) => R5, f6: (a: R5) => R6): (...args: A) => R6; <A extends any[], R1, R2, R3, R4, R5>(f1: (...args: A) => R1, f2: (a: R1) => R2, f3: (a: R2) => R3, f4: (a: R3) => R4, f5: (a: R4) => R5): (...args: A) => R5; <A extends any[], R1, R2, R3, R4>(f1: (...args: A) => R1, f2: (a: R1) => R2, f3: (a: R2) => R3, f4: (a: R3) => R4): (...args: A) => R4; <A extends any[], R1, R2, R3>(f1: (...args: A) => R1, f2: (a: R1) => R2, f3: (a: R2) => R3): (...args: A) => R3; <A extends any[], R1, R2>(f1: (...args: A) => R1, f2: (a: R1) => R2): (...args: A) => R2; (...func: Array<lodash.Many<(...args: any[]) => any>>): (...args: any[]) => any; }
Como você pode observer no código, o lodash e o ramda possuem um número finito de funções que podem ser encadeadas. Tudo bem que 7 funções para um pipe pode ser um exagero tremendo, mas caso você precise de extender isso ou apenas se desafiar a como resolver um problema de tipagem, você pode resolver utilizando os tipos recursivos + reduce.
Antes de tudo, é válido lembrar que a inferência do pipe acaba não sendo extensa, devido à limitação na inferência no rest parameters.
Se você leu o artigo de type reduce, você terá um pouco mais de contexto de como funciona a lógica desse tipo. Infelizmente o Typescript não nos ajuda na inferência dos tipos através do rest-parameter, como dito anteriormente. Porém, para contornar esse problema nós vamos utilizar algumas artemanhas da linguagem para poder resolver esse problema.
Como nós visamos receber pelo menos duas funções, os primeiros dois argumentos precisam ser especificados. Do terceiro em diante nós vamos aceitar quaisquer funções, não importa se sejam 10 ou nenhuma.
O problema do rest parameter nesse caso é que precisamos aplicar uma regra nos parâmetros, sendo que eles não foram recebidos e tratados da forma devida da linguagem. Alterar os parâmetros da função diretamente no construtor da função acabam confundindo o nosso type system e jogando toda a inferência para o lado any
da força.
Tendo isso em mente, ao invés de testar os parâmetros em sua entrada, por que nós não podemos modificar a saída em caso da entrada estar errada? Quê?????????????????????????????
Fica tranquilo, vamos entender um pouco melhor essa frase.
first
, second
e um rest
, sendo esse um rest parameter (N funções permitidas).
first
precisa extender (...params: any[]) => any
. Pois, a entrada pode ter N argumentos.second
e rest
precisam extender (a: any) => any
. Como todas as funções possuem somente um retorno, essas funções só podem ter uma entrada.pipe
será um retorno customizado baseado na entrada.
false
e armazenar o seu indexA lógica pode ser um pouco complicada, mas que tal a gente ir comentando o código para facilitar o entendimento? Se você preferir, pode olhar o playground
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97
// Utilitários para que possamos iterar os arrays import { F, N } from "ts-toolbelt"; // funções que recebem somente um argumento type Unary = (a: any) => any; // funções que recebem N argumentos type Func = (...a: any[]) => any; type Pipe< // primeira função de referência pipe First extends Func, // todas as funções passadas na nossa função pipe Fns extends Array<Func | Unary>, // acumulador de argumentos Acc extends Func[] = [], // contador do nosso tipo recursivo I extends number = 0 // Aqui testamos a condição para saber se chegamos ao fim do array > = Fns["length"] extends I // Caso o tamanho do array seja o mesmo do contador, retornamos o acumulador ? Acc : ( // Nesse ponto testamos para saber se é a primeira função de pipe I extends 0 ? // Caso seja a primeira função, utilizamos os parâmetros recebidos por ela Pipe<Fns[I], Fns, [...Acc, (...params: Parameters<First>) => ReturnType<First>], N.Add<I, 1>> // Caso não seja, utilizamos o retorno da função anterior como parâmetro : Pipe<Fns[I], Fns, [...Acc, (param: ReturnType<Fns[N.Sub<I, 1>]>) => ReturnType<Fns[I]>], N.Add<I, 1>> ) type ExtractInfo< // Todas as funções originais Fns extends Func[], // Todas as funções transformadas Transform extends Func[], // acumulador de erros Acc extends any[] = [], // contador I extends number = 0 // Fim da recursão > = Fns["length"] extends I ? Acc // recursão : ExtractInfo<Fns, Transform, [ ...Acc, ( // Aqui é feito um teste para ver se os parâmetros // da função transformada são iguais aos da função // original. Caso sejam iguais, um unknown é retornado. // Caso sejam diferentes, retornamos a própria função Parameters<Fns[I]> extends Parameters<Transform[I]> ? unknown : Fns[I] ) ], N.Add<I, 1>> // Aqui é um tipo recursivo utilitário para identificar se algum item do array // é uma função. Ele irá nos ajudar a identificar os erros type OneIsFunction<Tests extends unknown[], Result extends boolean = false, I extends number = 0> = Result extends true ? true : Tests["length"] extends I ? false : OneIsFunction<Tests, Tests[I] extends Func ? true : false, N.Add<I, 1>> // Aqui é um outro tipo recursivo que nos ajuda a identificar o index // da função com erro. type FunctionIndexError<Tests extends unknown[], I extends number = 0> = Tests[I] extends Func ? I : FunctionIndexError<Tests, N.Add<I, 1>> // Por último, temos o tipo que irá agregar toda a lógica explicada // Caso exista alguma função no nosso array de `Tests`, iremos retornar um objeto de erro. // Se tudo estiver certo, retornamos a assinatura correta do retorno do pipe. type CreatePipe<Fns extends Func[], Tests extends unknown[]> = OneIsFunction<Tests> extends true ? { message: "wrong-function"; errorAt: FunctionIndexError<Tests> functions: Tests // Podemos ler: // `Retorne uma função que os parâmetros são referentes // aos parâmetros da primeira função e o retorno seja // o tipo de retorno da última função` } : ((...params: Parameters<Fns[0]>) => ReturnType<Fns[N.Sub<Fns["length"], 1>]>) // Finalmente temos a nossa implementação de função pipe const pipe = <First extends Func, Second extends Unary, Rest extends F.Narrow<Unary[]>>(first: First, second: Second, ...rest: Rest): CreatePipe<Pipe<First, [First, Second, ...Rest]>, ExtractInfo<[First, Second, ...Rest], Pipe<First, [First, Second, ...Rest]>>> => ([first, second, ...rest] as Func[]).reduce((acc, fn) => (...args: any[]) => fn(acc(...args))) as any; // Aqui um pequeno teste const sum = (a: number, b: number) => { console.log({ a, b }) return a + b } const pow2 = (a: number) => Math.pow(a, 2) const mutateStringToArray = pipe( sum, pow2 ); const result = mutateStringToArray(2, 2) console.log(result)
Esse tipo deu trabalho, mas conseguimos e ainda descobrimos uma técnica interessante sobre como debuggar os nossos tipos, visto que agora nossa função pipe pode nos alertar sobre o index errado e qual a assinatura da função errada. Por hoje é só isso tudão. Espero que tenham gostado e até a próxima.