Home > Software engineering >  Creating a function type where the return type depends on the existence of an optional parameter
Creating a function type where the return type depends on the existence of an optional parameter

Time:12-01

Consider the following JS function and example usage:

const fooInteractor = function(state, foo) {
  if (foo) {
    return {...state, foo}
  } else {
    return state.foo
  }
}

const state = {foo: 'foo', other: 'data'};
fooInteractor(state); // returns 'foo'
fooInteractor(state, 'bar'); // return {foo: 'bar', other: 'data'}

// we pass this function around generically and use it to interact with state
function interacts(state, interactorFn) {
  // ie interactorFn(state);
}

interacts(state, fooInteractor);

How can I type this in TS land?

I can define a type and interaction with that type that Typescript is happy with:

type StateInteractor {
  (state: State): string
  (state: State, value: string): State
}
function interact(state: State, stateFn: StateInteractor) {
  const data: string = stateFn(state);
  const changedState: State = stateFn(state, data);
  // etc
}

However, when it actually comes to implementing StateInteractor I'm having issues:

const fooInteractor: StateInteractor = function(state: State, foo?: string) {
  if (foo) {
    return {...state, foo} as State
  } else {
    return state.foo as string
  }
}
/*
Type '(state: State, foo?: string) => string | State' is not assignable to type 'StateInteractor'.
  Type 'string | State' is not assignable to type 'string'.
    Type 'State' is not assignable to type 'string'.ts(2322)
*/

I also can't get arrow functions / lambda to work.

Note that I can do this with function overloading:

function fooInteractor(state: State): string
function fooInteractor(state: State, value: string): State
function fooInteractor(state: State, value?: string) {
  if (value) {
    return {...state, foo: value} as State
  } else {
    return state.foo as string
  }
}

// This compiles and works as expected
const typedFooInteractor: StateInteractor = fooInteractor
interact(someState, typedFooInteractor)

But this is super ugly and weird, and requires that I redefine the StateInteractor type (via overloading) every single time I implement one.

CodePudding user response:

We can squeeze the overload logic into a generic function signature using Generic Parameter Defaults and a Conditional Type as the return value. Playground version

V extends string | undefined = undefined (the generic default) says V is either string or undefined and by default undefined. The conditional return V extends string ? State : string uses ternary syntax to return based on V's type.

type State = Record<string, any>

// define a function type whose return value depends on an argument
type StateInteractor = <S extends State, V extends string | undefined = undefined>(state: S, value?: V) => V extends string ? State : string 

// define an instance
const fooInteractor: StateInteractor = (state: State, foo?: string) => {
  if (foo) {
    return {...state, foo}
  } else {
    return state.foo
  }
}

// test it
const state = {foo: 'foo', other: 'data'};
const shouldBeFoo = fooInteractor(state); // returns 'foo'
const shouldBeState = fooInteractor(state, 'bar'); // return {foo: 'bar', other: 'data'}
  • Related