The problem we are encountering is due to the lack of proper distinction between elements in the union. There is an overlap between two members
(FuncPagerArgs & BaseArgs) | BaseArgs
, with the overlap being equal to
BaseArgs
. It is crucial to understand the
behavior of function argument types in TypeScript:
When comparing function parameter types, assignment succeeds if either the source parameter can be assigned to the target parameter, or vice versa.
This means that if our type can be assigned to the desired type, or vice versa, the argument can be passed. Consider the following test:
type ArgType = {
title: "something",
enablePager: true
}
type IsAssignable = ArgType extends FuncArgs ? true : false; // true, can be passed
If the type you are passing is assignable to the desired type, TypeScript allows it to be passed. This flexibility is a deliberate design decision to accommodate various behaviors, as mentioned in the linked TypeScript documentation. However, there may still be runtime errors within the code.
To address this issue, we need to create a discriminated union without any overlaps.
interface BaseArgs {
title: string
}
interface OnlyTitle extends BaseArgs {
kind: 'TITLE'
}
interface FuncPagerArgs extends BaseArgs {
kind: 'PAGER'
enablePager: true
limit: number
count: number
}
type FuncArgs = OnlyTitle | FuncPagerArgs;
function func(args: FuncArgs) {
if (args.kind === 'PAGER') {
pager({ limit: args.limit, count: args.count });
}
}
interface PagerArgs {
limit: number
count: number
}
function pager(args: PagerArgs) {
}
func({
kind: "TITLE",
enablePager: true
}) // error
func({
kind: "TITLE",
title: 'title'
}) // correct
I introduced a discriminant property called kind
, which ensures that each member of the union is distinct without any overlap. Now, you can only use one variant at a time, preventing any overlap in types.
Edit after comment
In response to a comment requesting to combine all options together, while ensuring that when something is enabled, all related options should be included, we can achieve this by grouping and joining the options. Here's an example:
interface Base {
title: string
}
interface Pager {
pager?: {
limit: number
count: number
}
}
interface Sort {
sorter?: {
columns: string[] // example property
}
}
type Options = Pager & Sort & Base;
function func(args: Options) {
if (args.pager) {
pager({ limit: args.pager.limit, count: args.pager.count });
}
if (args.sorter) {
sorter(args.sorter.columns)
}
}
func({
title: 'title',
pager: {count: 2, limit: 10} // all fields its ok
})
func({
title: 'title',
pager: {count: 2} // error limit is missing
})
Now, we can have both pager
and sorter
together. When using pager
, all related options must be provided as they are required.