Skip to content

Instantly share code, notes, and snippets.

@jrson83
Created March 12, 2024 22:36
Show Gist options
  • Save jrson83/9a277b09ee323e3969a99b44cfa60c32 to your computer and use it in GitHub Desktop.
Save jrson83/9a277b09ee323e3969a99b44cfa60c32 to your computer and use it in GitHub Desktop.
typescript: how to infer array of generic objects as union
/**
* 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