1

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:

  • options O should be inferred based on supplied parameter options - after applying any defaults
  • if no, or only some options are supplied, then default options should be applied - as per opts
  • generic O should contain the type of opts with defaults applied - as it could change the shape of the returned output.
TrevTheDev
  • 2,616
  • 2
  • 18
  • 36
  • WHy do you create `opts` const ? – captain-yossarian from Ukraine Oct 04 '22 at 08:17
  • `opts` is just the final complete version of options provided to the function, including any defaults - and so will never change. – TrevTheDev Oct 04 '22 at 09:55
  • Do you want the compiler to keep track of the actual property *values* passed in (like, specifically `true` vs `false`)? Or just which *keys* are passed in? – jcalz Oct 04 '22 at 16:49
  • 1
    I suppose you could do [this](https://tsplay.dev/mM1QZm) but I don't know that it's good/sane. There are TS issues surrounding accurately representing generic spread/merge types, and the more we work on that the less "sane" things appear. In any case it looks like your typings are a bit backwards, since your `O` is the *output* of a merge operation, so it's unlikely you'll get the compiler to infer it from `options`. The reverse (have `O` be the type of `options` and then compute the output type) is at least plausibly inferrable. Does that address your question? Do you want me to write... – jcalz Oct 04 '22 at 17:16
  • 1
    ...up an answer? If so please comment and mention @jcalz to notify me. If not you can still notify me and tell me what's missing with my suggestion. – jcalz Oct 04 '22 at 17:16
  • @jcalz - yes the compiler should know the actual property values passed in - as they shape the type of the output. – TrevTheDev Oct 04 '22 at 23:32
  • @jcalz - the [solution](https://tsplay.dev/mM1QZm) you provided is a good pattern. Thank you. – TrevTheDev Oct 05 '22 at 01:04

1 Answers1

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360