Texto datilografado - inferir se uma função é curry

Sep 06 2020

Eu tenho uma função que converte funções "vanilla" de vários argumentos em funções curried. Se for passada uma função de argumento único, no entanto, ela a manterá como uma função vanilla. O texto digitado infere o tipo de retorno dessa função de transformação de função como a união da função vanilla e da função curried. Isso faz sentido, essas são as duas saídas possíveis.

Mas, quando tento chamar uma função curried retornada pela função do transformador, Typescript reclama porque não sabe se a função que estou chamando é o retorno curried ou o retorno simples.

Aqui está o código que ilustra isso:

// Types for illustration
type VanillaFunction = (foo: string) => string
type CurriedFunction = (bar: number) => VanillaFunction
type FunctionToTransform = (arg1: string, ...otherArgs: any[])=>string

const oneArgFunction = (myString: string) => myString
const twoArgFunction = (myString: string, myNum: number) => myString + myNum.toString()

const functionTransformer = (myFunction: FunctionToTransform) => {
    if(myFunction.length === 1) {
        return (arg1: string) => myFunction(arg1)
    } else {
        return (...otherArgs: any[]) => (arg1:string) => myFunction(arg1, ...otherArgs)
    }
}

const vanillaFunction = functionTransformer(oneArgFunction)
const curriedFunction = functionTransformer(twoArgFunction)

console.log(vanillaFunction("hello"))
//error here because TS doesn't know whether this is simple function or a curried function
console.log(curriedFunction(2)("hello"))

Agora, posso consertar dizendo explicitamente ao TS os tipos de retornos, ou seja:

const vanillaFunction = functionTransformer(oneArgFunction) as VanillaFunction
const curriedFunction = functionTransformer(twoArgFunction) as CurriedFunction

Mas existe uma maneira de fazer o compilador inferir o tipo correto?

ATUALIZAR:

Pensando um pouco mais nisso, parece que os protetores de tipo deveriam me ajudar, ajudando o TS a discriminar as entradas e saídas da função de transformação. Então eu tentei isso:

// Types for illustration
type VanillaFunction = (arg1: string) => string
type CurriedFunction = (arg2: any, ...otherArgs: any[]) => VanillaFunction
type FunctionToTransformSingleArg = (arg1: string)=>string
//Adding the additional non-optional argument is necessary to make FunctionToTransformSingleArg incompatabile
//otherwise the type guard won't work
type FunctionToTransformWithExtra = (arg1: string, arg2: any, ...otherArgs: any[])=>string
type FunctionToTransform = FunctionToTransformSingleArg | FunctionToTransformWithExtra

function isFunctionToTransformSingleArg(functionToTransform: FunctionToTransform): functionToTransform is FunctionToTransformSingleArg {
    return functionToTransform.length === 1
}

function isCurriedFunction(ambiguousFunction: VanillaFunction | CurriedFunction): ambiguousFunction is CurriedFunction {
    return typeof ambiguousFunction("test string") === "function"
}

const oneArgFunction:FunctionToTransform = (myString: string) => myString
const twoArgFunction:FunctionToTransform = (myString: string, myNum: number) => myString + myNum.toString()

const functionTransformer = (myFunction: FunctionToTransform) => {
    let returnFunction: VanillaFunction | CurriedFunction
    if(isFunctionToTransformSingleArg(myFunction)) {
        returnFunction = (arg1: string) => myFunction(arg1)
    } else {
        returnFunction = (arg2: any, ...otherArgs: any[]) => (arg1:string) => myFunction(arg1, arg2, ...otherArgs)
    }
    return isCurriedFunction(returnFunction) ? returnFunction : returnFunction
}

const vanillaFunction = functionTransformer(oneArgFunction)
const curriedFunction = functionTransformer(twoArgFunction) 

console.log(vanillaFunction("hello"))
console.log(curriedFunction(2)("hello"))
//              ^ -- Error here:  "Type 'string' has no call signature"

Mas como você pode ver, por algum motivo, ainda não está inferindo o tipo correto para a função curried, apesar do tipo de guia. Parece estar inferindo o tipo de retorno do tipo CurriedFunction em vez do tipo completo. Alguma ideia por quê?

O código também está neste playground .

Respostas

sam256 Sep 07 2020 at 01:18

Eu descobri. O problema com a abordagem de proteção de tipo acima é que eu estava tentando usar uma proteção de tipo em um retorno de função . O problema, agora percebo, é que isso realmente não ajuda o compilador. Claro que diz ao compilador como discriminar entre dois tipos de funções que serão retornadas, mas (sem mais informações) qual caminho foi seguido para chegar a um dos dois tipos de funções de retorno não é conhecido até o tempo de execução.

Acontece que a chave é dar ao compilador mais algumas informações, dizendo-lhe efetivamente: "você pode descobrir o tipo de retorno da função a partir do tipo de entrada". Em outras palavras, um tipo condicional baseado no tipo de entrada (conforme estreitado por um protetor de tipo no tipo de entrada , não no tipo de retorno).

Basicamente, você está dizendo ao compilador se a entrada x para a função é do tipo Y (conforme determinado por um guarda), então o tipo de retorno será do tipo A, se a entrada x for do tipo Z (novamente, conforme determinado pelo guarda), então o tipo de retorno será tipo B.

Isso também foi complicado pelo fato de que uma das assinaturas de função é compatível com a outra, portanto, sem alguma tipificação / marca nominal, o tipo condicional não funcionaria.

Aqui está a solução (mas para onde foi meu domingo!):

// These are the types we ultimately want TS to distinguish between: plain functions and curried
type VanillaFunction = (arg1: string) => string
type CurriedFunction = (arg2: any, ...otherArgs: any[]) => VanillaFunction

// This branding is needed to make the two function signatures incompatible.  Normally a function
// having fewer parameters is compatible with one more arguments, for good reason.
// See: https://github.com/Microsoft/TypeScript/wiki/FAQ#why-are-functions-with-fewer-parameters-assignable-to-functions-that-take-more-parameters
type FunctionToTransform = ((arg1: string) => string) | ((arg1: string, ...otherArgs: any[])=>string)
type FunctionToTransformSingleArg = {type: "single", function: (arg1: string)=>string}
type FunctionToTransformWithExtra = {type: "multiple", function: (arg1: string, ...otherArgs: any[])=>string}
type FunctionToTransformBranded = FunctionToTransformSingleArg | FunctionToTransformWithExtra

// Type guard for the function we are passing in
function isFunctionToTransformSingleArg(functionToTransform: FunctionToTransformBranded): functionToTransform is FunctionToTransformSingleArg {
    return functionToTransform.type === "single"
}

// Creator functions to tell TS whether we are passing in a function with one argument or 
// more via type branding (see above).  Otherwise, our conditional type below wouldn't work 
// because everything would resolve to the same type-compatible signature for the multi-argument 
// function
function createOneArgFunction(fn: FunctionToTransform):FunctionToTransformSingleArg {
    return {type: "single", function:fn}
}
function createTwoArgFunction(fn: FunctionToTransform): FunctionToTransformWithExtra {
    return {type: "multiple", function: fn}
}

const oneArgFunction = createOneArgFunction((myString: string) => myString)
const twoArgFunction = createTwoArgFunction((myString: string, myNum: number) => myString + myNum.toString())

const functionTransformer = <T extends FunctionToTransformBranded>(myFunction: T) => {
    let returnFunction
    if(isFunctionToTransformSingleArg(myFunction)) {
        returnFunction = (arg1: string) => myFunction.function(arg1)
    } else {
        returnFunction = ( ...otherArgs: any[]) => (arg1:string) => myFunction.function(arg1, ...otherArgs)
    }
    // Conditional type that tells the compiler it can infer the return type on the basis of the input type, as
    // narrowed by the type guard
    return returnFunction as typeof myFunction extends FunctionToTransformSingleArg ? VanillaFunction : CurriedFunction
}

const vanillaFunction = functionTransformer(oneArgFunction)
const curriedFunction = functionTransformer(twoArgFunction) 

console.log(vanillaFunction("hello"))
console.log(curriedFunction(2)("hello"))

console.log(vanillaFunction("hello")("hello"))
//                  ^--  Properly errors because this not a curried function
//                       and will fail at runtime

Aqui está o código em um playground .