Reputation: 2737
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:
O
should be inferred based on supplied parameter options
- after applying any defaultsopts
O
should contain the type of opts
with defaults applied - as it could change the shape of the returned output.Upvotes: 1
Views: 148
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.
Upvotes: 1