Search code examples
typescripttypes

TypeScript types - accessing function's argumets Tuple type from function call (Fn type generated by another type)


Edit: I completely changed question after playing with this a little today: Playground

I start from the end, with example that for sake of simplicity wants to get a correct first argument user provided:

const functionWithoutRestrictedParams = <Args extends any[]>(...args: Args): Args[0] => {
    return '' as any
}

const stringResult = functionWithoutRestrictedParams('first', 'second')

const undefinedResult = functionWithoutRestrictedParams(undefined, 'second')

Here is a function that takes in any number of arguments and has return type as the type of first argument user provided.

Now lets get to typing for function parameters: First just a simple type that takes types of function parameters and makes every argument optional:

export type FnWithOptions<A extends any[]> = {[I in keyof A]: A[I] | undefined}

type BasicFn<Params extends any[]>
    = (...args: FnWithOptions<Params>) => void


const fn:BasicFn<[first: string, second: number]> = (first, second) => {}

fn('test', 1)
fn(undefined, 1)
//@ts-expect-error
fn(1,1)

This is to show what kind of types intended Api uses, every parameter has possibility to be optional.

Now attempt that I believed was closest to working, but sadly the resulting type is any, and so type information got lost.

type MockTransformer<Params, Args> = Params

type Attempt<Params extends any[]>
    = <Args extends any[]>(...args:MockTransformer<FnWithOptions<Params>, Args>) => Args[0]

const attempt:Attempt<[first: string, second: number]> = (first, second) => {}

const expectedString = attempt("str", 1)
const expectedUndefined = attempt(undefined, 1)

Here I tried to combine two previous examples, first defining required Params that are always provided when defining function. Then defining Function type. My reasoning here was to MockTransformer type that takes Params and Args, returns Params so that is type of data user can provide. Then I hoped that actual arguments that function is called with gets bound to Args with type information retained, and that I will get correct type of Return either undefined or string depending on value provided. This however is 'any' type. Is there something that can be done in this situation, or am I just out of luck here?


Solution

  • If you have a tuple type P of function parameters and you want to make a function that can keep track of the types of the arguments passed in, even if these types are narrower than P, then you can make the function generic:

    <A extends P>(...args: A) => void
    

    Now if you call the function, TypeScript will infer A based on the types of the actual arguments. The types of these arguments are constrained to P, so if A is [string] then you can't call the function with a number.

    If the function is supposed to return the type of that first argument, then you can return the indexed access type A[0]:

    <A extends P>(...args: A) => A[0]
    

    In your specific case, you don't actually want to accept P, but a modified version of P where each element is possibly undefined. So instead of A extends P, you want A extends UndefinableElements<P> where UndefinableProps is a mapped array/tuple type defined as:

    type UndefinableElements<T extends any[]> =
        { [I in keyof T]: T[I] | undefined }
    

    So if P is [string] then UndefinableElements<P> is [string | undefined]. And so your function type looks like:

    type Attempt<P extends any[]> =
        <A extends UndefinableElements<P>>(...args: A) => A[0]
    

    Again, what's happening here is that Attempt<P> will turn the tuple type P into a version where all elements can also accept undefined, and then it will become a generic function where the argument list is some generic type A constrained to the modified version of P, and where it returns the type of the first argument A[0]. Let's test it:

    const attempt: Attempt<[first: string, second: number]> =
        (first, second) => first
    
    const expectedString = attempt("str", 1);
    //    ^? const expectedString: "str"
    
    const expectedUndefined = attempt(undefined, 1);
    //    ^? const expectedUndefined: undefined
    
    attempt(0, 1); // error!
    //      ~
    // Argument of type 'number' is not assignable to parameter of type 'string'.
    

    Looks good. The first call has A inferred as ["str", 1], and thus A[0] is "str", while the second call has A inferred as [undefined, 1], and thus A[0] is undefined. And the third call produces an error because the type [0, 1] TypeScript wants to infer for A does not match UndefinableElements<[first: string, second: number]> which is [first: string | undefined, second: number | undefined].

    Playground link to code