Created
March 12, 2024 22:36
-
-
Save jrson83/9a277b09ee323e3969a99b44cfa60c32 to your computer and use it in GitHub Desktop.
typescript: how to infer array of generic objects as union
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Helper type-level function to expand a given type to show all of its inferred fields when hovered. | |
* | |
* @see {@link https://stackoverflow.com/a/57683652} | |
* @see {@link https://gist.github.com/trevor-atlas/b277496d0379d574cadd3cf8af0de34c} | |
*/ | |
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never | |
/** | |
* Represents an argument. | |
*/ | |
type ParseArgsArgumentConfig = { | |
/** | |
* Whether this option can be provided multiple times. | |
* If `true`, all values will be collected in an array. | |
* If `false`, values for the option are last-wins. | |
* @defaultValue false. | |
*/ | |
multiple?: boolean | |
/** | |
* The default option value when it is not set by args. | |
* When `multiple` is `true`, it must be an array of strings. If `false` a string. | |
*/ | |
default?: unknown | |
/** | |
* Whether this argument is required. | |
* @defaultValue false. | |
*/ | |
required?: boolean | |
/** | |
* The description of the argument. | |
*/ | |
description?: string | |
} | |
/** | |
* Represents an object holding multiple arguments. | |
*/ | |
type ParseArgsArgumentsConfig = Record<string, ParseArgsArgumentConfig> | |
/** | |
* Helper type to validate and ensure consistency in the types of default values for a single argument. | |
* | |
* @template T - The type of the argument configuration. | |
*/ | |
type ValidateArgType<T> = T extends ParseArgsArgumentConfig | |
? /** | |
* If the argument allows multiple values, the default value must be an array of strings. | |
*/ | |
T['multiple'] extends true | |
? Expand<Omit<T, 'default'> & { default?: string[] }> | |
: /** | |
* If the argument does not allow multiple values, the default value must be a string. | |
*/ | |
Expand<Omit<T, 'default'> & { default?: string }> | |
: T | |
/** | |
* Type to validate and ensure consistency in the types of default values for a set of arguments. | |
* | |
* @template T - The type of the arguments configuration. | |
*/ | |
type Validate<T extends ParseArgsArgumentsConfig> = { | |
/** | |
* For each argument key in the configuration, validate and ensure consistency in the types of default values. | |
*/ | |
[K in keyof T]: ValidateArgType<T[K]> | |
} | |
/** | |
* Represents the options that define a program. | |
*/ | |
type Program< | |
DefaultArgs extends ParseArgsArgumentsConfig, | |
SubArgs extends ParseArgsArgumentsConfig, | |
> = { | |
name: string | |
command: Command<DefaultArgs, SubArgs> | |
} | |
/** | |
* Represents the options that define a command. | |
*/ | |
type Command< | |
DefaultArgs extends ParseArgsArgumentsConfig, | |
SubArgs extends ParseArgsArgumentsConfig, | |
> = { | |
name: string | |
args: Validate<DefaultArgs> | |
subCommands: SubCommand<SubArgs>[] | |
} | |
/** | |
* Represents the options that define a sub-command. | |
*/ | |
type SubCommand<SubArgs extends ParseArgsArgumentsConfig> = { | |
name: string | |
args: Validate<SubArgs> | |
} | |
/** | |
* Validates and ensures consistency in the types of default values based on the 'multiple' property for each argument. | |
* Throws an error if the types do not match the expected structure. | |
* | |
* @param {Validate<T>} data - The arguments configuration to be validated. | |
* @returns {Validate<T>} The validated arguments configuration. | |
* @throws {Error} Error if the 'default' property type mismatches with the 'multiple' property. | |
*/ | |
const validateArguments = <const T extends ParseArgsArgumentsConfig>( | |
data: Validate<T> | |
): Validate<T> => { | |
if (!data || typeof data !== 'object') { | |
throw new TypeError('Argument must be a non-empty object.') | |
} | |
for (const key in data) { | |
const arg = data[key] | |
/** | |
* If 'multiple' is 'true', the validation error will be thrown only if 'default' is defined and not an array. | |
*/ | |
if ( | |
arg.multiple && | |
arg.default !== undefined && | |
!Array.isArray(arg.default) | |
) { | |
throw new TypeError( | |
`Invalid type for 'default' property in argument '${key}'. Expected 'string[]' but got '${typeof arg.default}'.` | |
) | |
} | |
/** | |
* If 'multiple' is 'false', the validation error will be thrown only if 'default' is defined and not a string. | |
*/ | |
if ( | |
!arg.multiple && | |
typeof arg.default !== 'string' && | |
arg.default !== undefined | |
) { | |
throw new TypeError( | |
`Invalid type for 'default' property in argument '${key}'. Expected 'string' but got '${typeof arg.default}'.` | |
) | |
} | |
} | |
/** | |
* The function returns the validated arguments configuration. | |
* Note: It should infer the type without explicitly specifying 'as Validate<T>'. | |
*/ | |
return data | |
} | |
function buildProgram< | |
const DefaultArgs extends ParseArgsArgumentsConfig, | |
const SubArgs extends ParseArgsArgumentsConfig, | |
>(v: Program<DefaultArgs, SubArgs>) { | |
return { | |
name: v.name, | |
command: { | |
...v.command, | |
args: validateArguments(v.command.args), | |
subCommands: v.command.subCommands.map((subCommand) => ({ | |
...subCommand, | |
arguments: validateArguments(subCommand.args), | |
})), | |
}, | |
} | |
} | |
const prog = buildProgram({ | |
name: 'test', | |
command: { | |
name: 'default', | |
args: { | |
arg1: { | |
multiple: true, | |
default: ['xxx1'], | |
}, | |
arg2: { | |
default: 'xxx2', | |
}, | |
arg3: { | |
multiple: true, | |
// @ts-expect-error: should throw error `Type 'string' is not assignable to type 'string[]'.ts(2322)` | |
default: 'xxx3', | |
}, | |
arg4: { | |
// @ts-expect-error: should throw error `Type 'string[]' is not assignable to type 'string'.ts(2322)` | |
default: ['xxx4'], | |
}, | |
}, | |
subCommands: [ | |
{ | |
name: 'cmd1', | |
args: { | |
subArg1: { | |
multiple: true, | |
default: ['xxx5'], | |
}, | |
subArg2: { | |
default: 'xxx6', | |
}, | |
}, | |
}, | |
{ | |
name: 'cmd2', | |
args: { | |
/** | |
* problem: buildProgramm `generic type parameters` for `SubArgs` are not infered as `union` | |
* | |
* @see {@link https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types} | |
*/ | |
subArg3: { | |
multiple: true, | |
default: ['xxx7'], | |
}, | |
subArg4: { | |
default: 'xxx8', | |
}, | |
}, | |
}, | |
], | |
}, | |
}) | |
console.log(prog) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment