TrevTheDev
TrevTheDev

Reputation: 2737

Type patterns for applying options in functions

What is a good/sane pattern for typing options in functions?

type DummyType<T>=T 

type Options = {
  optionX: boolean
  optionY: boolean
  ...
}

const exampleFn = <T,O extends Options>(arg: T, options?: Partial<O>)=>{
  // opts below is a combination of `options` and the relevant defaults
  // opts should ALWAYS match `O`
  const opts: O = {
    optionX: false,
    optionY: true, ...options
  }
  console.log(arg, opts)

  ...

  // return type may be different based on supplied `O`
  return { whatever: arg } as unknown as DummyType<O['optionX']>
}

Ideally:

Upvotes: 1

Views: 148

Answers (1)

jcalz
jcalz

Reputation: 329523

Your typings seem backwards to me. Your O type is only really applicable to the output of merging an object with options, but the only type from which the compiler can easily infer is the type of options, the input. If we switch things around so that O is the type of options, then we can try to compute the output type in terms of O explicitly.


One issue is that when you write { optionX: false, optionY: true, ...options} and options is of a generic type like O, the compiler approximates the type of the result with an intersection, like { optionX: false, optionY: true } & O. That type is fine if O doesn't have the keys optionX or optionY, but fails pretty badly if it does have those keys. A plain intersection fails to capture the results of overwriting properties.

To do better we need to start writing our own helper types and asserting that a spread results in a value of those types. It's probably out of scope to go into exactly how to best do this and what the pitfalls are. You can look at Typescript, merge object types? for details. For now let's pick something that works well enough as long as the merged object doesn't have declared optional properties which happen to be missing:

type Merge<T, U> = { [K in keyof T | keyof U]: 
  K extends keyof U ? U[K] : K extends keyof T ? T[K] : never };
const merge = <T, U>(t: T, u: U) => ({ ...t, ...u }) as Merge<T, U>;

Let's test that:

const test = merge(
    { a: 1, b: 2, c: 3 },
    { b: "two", c: "three", d: "four" }
);
/* const test: {
    a: number;
    b: string;
    c: string;
    d: string;
} */
console.log(test.c.toUpperCase()) // "THREE"

Looks good. The compiler understands that b and c are overwritten with string values instead of number values.


Okay, so here's how I'd approach this:

const defaultOpts = { optionX: false, optionY: true } as const;
type DefaultOpts = typeof defaultOpts;

function exampleFn<T, O extends Partial<Options> = {}>(
    arg: T, options?: O) {
    const o = options ?? {} as O; // assert here
    const opts = merge(defaultOpts, o);
    console.log(arg, opts)
    const ret: DummyType<Merge<DefaultOpts, O>['optionX']> = opts.optionX; // okay
    return ret;
}

First, I moved the set of default options into its own variable named defaultOptions, and had the compiler compute its type and gave that the name DefaultOptions. When we merge options of type O into that, the result will be of type Merge<DefaultOpts, O>.

Then we want exampleFn() to be called in two ways: either with two arguments, in which case options will be of type O, or with one argument, in which case options will be undefined and we'd like O to default to being just the empty type {}.

So I assign o to be a value of type O, and I need to assert that {} is of type O when options is undefined, because it's technically possible for this not to be true (but I'm not worrying about that possibility).

Then opts is of type Merge<DefaultOptions, O>.

For the returned value I just index into opts with optionX to give a value of type DummyType<Merge<DefaultOpts, O>['optionX']> (because DummyType<T> is just the identity type; if you change DummyType then you need to change the code to match, or use an assertion as you were doing before).


Okay, let's test that typing:

exampleFn({}, {}) // false
exampleFn({}, { optionX: true }) // true
exampleFn({}, { optionX: false }) // false
exampleFn({}); // false
exampleFn({}, { optionY: false, optionX: undefined }) // undefined 

This all works well enough, I think. Note that it's a bit weird for someone to explicitly pass in undefined for a property, but by default optional properties do accept that.

Note that the following call gives the wrong output type:

exampleFn({}, Math.random() < 0.5 ? {} : { optionX: true }) // true | undefined 👎

That's because my definition of Merge doesn't take into account the possibility that the optionX property of the passed-in options argument might be missing. It assumes it's present-and-undefined, and so the output type is mistakenly produced as true | undefined instead of the actual true | false. I'm not worried too much about this; the point here is just to note that there are potential pitfalls with just about any definition of Merge, and you'll need to decide where to stop caring. I assume that options argument isn't going to generally be of a union type so the mistake here doesn't matter much. But you should definitely test against your use cases and tweak Merge if you have to.

Playground link to code

Upvotes: 1

Related Questions