2

I order to abstract away common functionality it would be nice to have a function that takes a lambda as input and also its parameters.

The function should the be callable like

const lambdaFn = (param1: string, param2: number) => {
  console.log(`Lambda - Param 1: ${param1}, Param 2: ${param2}`);
};

foo(lambdaFn, { param1: "test", param2: 1})

This would allow to call the function with its parameters. While they are passed as separate paremeters. The input params of the function would still have to match the pattern defined.

so far i came up with

function foo<T extends (...args: any[]) => void>(lambda: T, ...options: Parameters<T>): void {
  lambda(...options);
}

with allows to call

foo(lambdaFn, 'test', 1);

and

type Params<T extends (...args: any[]) => any> = {
  [K in keyof Parameters<T>]: Parameters<T>[K];
};

function foo<R, T extends (...args: any[]) => R>(lambda: T, options: Params<T>) {
  lambda(...(Object.values(options) as Parameters<T>));
}

which allows to call

foo(lambdaFn, ['test', 2]);

However these do not match my expectation. since the first approach makes it hard to pass additional parameters at the end, e.g. config parameters. Providing an array like in the second one does not show the connection to the lambda in my opinion.

The ultimate goal of this is to create a custom hook for networks calls in react. where the lambda function internally should trigger a network request and the function parameters a are query params the body or other parts of the network call. the approach foo(lambdaFn, 'test', 1); would be fine but is not usable if I would like to add a config parameter in the future.

5
  • 2
    Have you considered foo(lambdaFn.bind(null, 'test', 1)) to sidestep the entire issue?
    – deceze
    Commented Oct 17, 2024 at 13:19
  • @deceze I did not know this was possible so far. But still not sure if this is an improvement, Though I like the solution. Thank you
    – tscheppe
    Commented Oct 18, 2024 at 7:46
  • 2
    In general, whenever you pass a callback to something, that something shouldn't need to worry about passing additional arguments through. Just pass a callable that already has all its arguments. Alternative: foo(() => lambdaFn('test', 1)). That just simplifies the design of callbacks massively.
    – deceze
    Commented Oct 18, 2024 at 7:49
  • @deceze passing the callable with the arguments already present will not allow me to have additional logic on the paramters in the invoked function. This will for example make it impossible to retrigger a network call on changing parameters in in react.
    – tscheppe
    Commented Oct 19, 2024 at 9:51
  • @tscheppe what if you add the config in future like this? Did I miss something? What would be the expected "future" version of the function?
    – Teneff
    Commented Oct 22, 2024 at 14:26

3 Answers 3

1

I think the issue here is that you are thinking in terms of some pythonic approach where the keyword arguments a.k.a kwargs(ie object version of arguments) is interchangeable. You are right that there will be no connection between your object and what's provided to your function since the ordering of your arguments will not be guarantied by getting the values of some arbitrary object. In javascript you always rely on argument ordering. If I was you I would stick to providing arguments as objects and in fact this pattern is seen in several libraries and on I can think of is aws cdk.

So if I couldn't modify the signature of the original function to be an object I would do:

function foo<T,R>(
  fn: (props:T) => R,
  props: T
): void {
  return fn(props);
}

const lambdaFn = (param1: string, param2: number) => {
  console.log(`Lambda - Param 1: ${param1}, Param 2: ${param2}`);
};

foo(({param1, param2}=>lambdaFn(param1, param2))), { param1: "test", param2: 1})

Now If you don't wat to write a wrapper for every function, you can have a foo function as you described that accepts argument order this way:

type Head<L>= L extends [infer H, ...unknown[]]? H:never;
type Tail<L> = L extends [unknown, ...(infer T)]? T:never;
type PropArguments<
T,P extends (keyof T)[]
> = P extends []?[]:  [T[Head<P>],...PropArguments<T,Tail<P>>]


function foo<T,P extends (keyof T)[], Z>(fn: (...a:PropArguments<T,P >)=>Z, params: P, props: T){
    return fn(...params.map((k)=>props[k])  as PropArguments<T,P >) 
}

function sampleFn(a: string ,b: Date){
    return [a,b].join(':')
}

So this would work:

const result = foo(sampleFn, [ 'a', 'b'] as const, {a:'The date was', b: new Date('2024-10-10T10:10'), })

or since names don't matter and the actual order (and type matched) of the arguments you give is important, this would also work

const result2 = foo(sampleFn, [ 'b', 'a'] as const, {b:'The date was', a: new Date('2024-10-10T10:10'), })
//and also this
const result = foo(sampleFn, [ 'description', 'date'] as const, {description:'The date was', date: new Date('2024-10-10T10:10'), })

but this would throw on compile time

const result3 = foo(sampleFn, [ 'b', 'a'] as const,{a:'The date was', b: new Date('2024-10-10T10:10'), })

because it would map to the wrong type or argument (first date and second string, whereas you want first string and second date)

PS :I only did the second approach as a Typescript Type challenge, just for fun, but I would definitely go for the first since it looks more practical to me.

0
+50

Alright, if you want to have arbitrary arguments, you will have to do a double conversion in your params type:

Key part of the implementation: [K in keyof Parameters<T> as Names[K & number]]

You remap your Tuple Parameters<T> [0, string], [1, number] back into arbitrary arguments. Names[K & number] will give you the actual name of the parameter. More on it in documentation

Option 1

type NamedParams<T extends (...args: any[]) => any, Names extends string[]> = {
  [K in keyof Parameters<T> as Names[K & number]]: Parameters<T>[K];
};

// Modified `foo` function
function foo<R, T extends (...args: any[]) => R, Names extends string[]>(
  lambda: T,
  options: NamedParams<T, Names>,
  config?: { [key: string]: any }
): void {
  // Convert the `options` object into an array based on the parameter names.
  const args = Object.values(options) as Parameters<T>;

  console.log("Config:", config);

  lambda(...args);
}

// Example lambda function that takes two parameters: a string and a number.
const lambdaFn = (name: string, age: number) => {
  console.log(`Lambda - Name: ${name}, Age: ${age}`);
};

// Call `foo` with named parameters
foo(lambdaFn, { name: "Alice", age: 30,  }, { timeout: 5000 });

UPD (answer to comment):

Given your latest input in a last comment, I don't think there is an elegant way to deal with compile-time type checking of parameter fields.

  • First, typescript guide demonstrates how function parameters are compared structurally, ignoring parameter names
  • Secondly here is the thread and the discussion around the topic of typing

Given all that, I can only suggest the simplifed solution, which would use destructuring and explicit parameter typing using decorator function over the original one. This way you could still call you foo function with complete type safety, but it will require writing decorators for each function you would like to use, unless it uses Object {} as parameters by default.

Option 2

type TypedFunction<T extends Record<string, any>> = (params: T) => void;

function foo<T extends Record<string, any>>(
  fn: TypedFunction<T>,
  params: T,
  config?: { [key: string]: any }
): void {
  console.log("Config:", config);
  fn(params);
}

// Usage with destructuring
const decoratedProcessUser = ({ name, age }: { name: string; age: number }) => {
  // return originalProcessUser(name, age);
};

// Type-safe usage
foo(decoratedProcessUser, { name: "Alice", age: 30 }, { timeout: 5000 });

Also, just to resurface a different approach. The whole goal is achievable in a much simpler way, not sure though if that fits your purpose:

Option 3

function foo(
  fn: () => any,
  config?: { [key: string]: any }
): void {
  console.log("Config:", config);
  fn();
}

const originalProcessUser = (name: string, age: number) => {
  console.log(`${name}: ${age}`)
};

// Type-safe usage
foo(() => { return originalProcessUser("Alice", 30); }, { timeout: 5000 });
7
  • Thank you for your answer. This solution still has the issue that it assumes all parameters be of the the name param*. Parameters should be of arbitrary name. The options parameter to foo should define be an object with members that are the parameters to foo. This is important to tackle wrong parameter passing.
    – tscheppe
    Commented Oct 25, 2024 at 6:34
  • @tscheppe I've updated my answer, let me know if I understood your assignment correctly. Really curious how to make it work now :) Commented Oct 25, 2024 at 8:07
  • note, this works for typescript 4.1 and onwards Commented Oct 25, 2024 at 8:15
  • This allows to execute the expected behavior. However, the options object is not typed. It would be nice if the type system could check if all variables are present and have the right type.
    – tscheppe
    Commented Oct 25, 2024 at 11:40
  • @tscheppe I've updated the answer once again. After surfing over the internet around parameter typing topics, I'm 90% certain the goal is not achievable without introduction of an additional closure over the original function. Commented Oct 25, 2024 at 21:03
0

This approach provides flexibility, allowing to work with any function signature, regardless of the number of parameters, also is type safety.

type Params<T extends (...args: any[]) => any> = {
  args: Parameters<T>;
  config?: any;
};

function foo<T extends (...args: any[]) => any>(lambda: T, options: Params<T>) {
  const { args, config } = options;
  
  // Call the lambda with the provided arguments
  lambda(...args);

  // Config is available here if needed
  if (config) {
    console.log("Config:", config);
  }
}

// Usage example
const greeter = (person: Person) => {
  const text = "Hello, " + person.firstName + " " + person.lastName;
  document.querySelector("#app")!.innerHTML = text;
};

interface Person {
  firstName: string;
  lastName: string;
}

let user = {
  firstName: "Malcolm",
  lastName: "Reynolds"
};

foo(greeter, { args: [user], config: { retry: true } });

// Example with more parameters
const sum = (a: number, b: number, c: number) => {
  alert("Sum:"+ (a + b + c));
};

foo(sum, { args: [1, 2, 3], config: { logging: true } });

Not the answer you're looking for? Browse other questions tagged or ask your own question.