If we have a schema describing paths, nested methods, and then their characteristics:
interface Schema {
'aaa': {
'zzz': { result: 'AAA-ZZZ', action: { path: 'bbb', method: 'xxx' } }
'xxx': { result: 'AAA-XXX', action: { path: 'ccc', method: 'zzz' } }
};
'bbb': {
'xxx': { result: 'BBB-XXX', action: { path: 'ccc', method: 'zzz' } }
};
'ccc': {
'zzz': { result: 'CCC-ZZZ', action: { path: 'aaa', method: 'zzz' }, content: string }
}
}
The goal is to type a function that will take an action description, and only when necessary use a second argument to get the content.
For example:
doSomething({ path: 'aaa', method: 'xxx' }) // return "AAA-XXX", no second argument.
doSomething({ path: 'aaa', method: 'xxx' }, "content string") // Should fail because this path/method does not use a content.
doSomething({ path: "ccc", method: "zzz" }, "content string") // return "CCC-ZZZ", takes a second argument to specify the content.
doSomething({ path: "ccc", method: "zzz" }) // Should fail because a second argument is missing
I found this answer that helped a lot building something that approach the desired result.
Here is what I have with a more simple schema:
interface Schema {
'aaa': { result: string };
'bbb': { result: number };
'ccc': { result: boolean, content: string };
}
type DoSomethingArgs = {
[Key in keyof Schema]: [key: Key, ...rest: Schema[Key] extends {
content: infer InferredContent
} ? [specificContent: InferredContent] : []]
}[keyof Schema]
function doSomething<
Args extends DoSomethingArgs,
Key extends Args[0],
Out = Schema[Key]['result']
>(...args: Args): Out {
return undefined as Out; // Not the point.
}
// Calling tests
const res_1 = doSomething("aaa"); // OK, result is string
const res_2 = doSomething("bbb"); // OK, result is number
const res_3 = doSomething("bbb", 'wrong content'); // OK: TS2345: Argument of type string is not assignable to parameter of type never
const res_4 = doSomething("ccc", 123); // OK: TS2345: Argument of type number is not assignable to parameter of type string
const res_5 = doSomething("ccc", 'some content'); // OK, result is boolean
const res_6 = doSomething("ccc"); // OK: Should fail because content is required
// ^^^^^
// TS2345: Argument of type ["ccc"] is not assignable to parameter of type DoSomethingArgs
// Type ["ccc"] is not assignable to type [key: "ccc", specificContent: string]
// Source has 1 element(s) but target requires 2
/* All okay \o/, but example is too simple */
I managed to have a result with the desired schema, but the code is using // @ts-ignore
:
interface Schema {
'aaa': {
'zzz': { result: 'AAA-ZZZ', action: { path: 'bbb', method: 'xxx' } }
'xxx': { result: 'AAA-XXX', action: { path: 'ccc', method: 'zzz' } }
};
'bbb': {
'xxx': { result: 'BBB-XXX', action: { path: 'ccc', method: 'zzz' } }
};
'ccc': {
'zzz': { result: 'CCC-ZZZ', action: { path: 'aaa', method: 'zzz' }, content: string }
}
}
type DoSomethingArgs = {
[Path in keyof Schema]: {
[Method in keyof Schema[Path]]: [
out: Schema[Path][Method] extends { result: infer Result } ? Result : never,
action: {
path: Path,
method: Method
},
...rest: Schema[Path][Method] extends { content: infer InferredContent } ? [specificContent: InferredContent]
: [],
]
}[keyof Schema[Path]]
}[keyof Schema]
function doSomething<
Args extends DoSomethingArgs extends [unknown, ...infer Rest] ? Rest : never,
// Here is the issue !
// Without the ts-ignore we have the following errors:
// TS2536: Type Args[0]["method"] cannot be used to index type Schema[Args[0]["path"]]
// TS2536: Type "result" cannot be used to index type Schema[Args[0]["path"]][Args[0]["method"]]
// @ts-ignore
Out extends Schema [Args[0]["path"]] [Args[0]["method"]] ['result']
>(...args: Args): Out {
return undefined as Out; // Not the point.
}
// Calling tests
declare const schema: Schema;
const res_00 = doSomething({ path: '', method: '' }); // Ok: Fails because action type is incorrect
const res_01 = doSomething({ path: 'aaa', method: 'xxx' }); // Ok, return type is "AAA-XXX"
const res_10 = doSomething({ path: "bbb", method: "xxx" }); // Ok, return type is "BBB-XXX"
const res_11 = doSomething(schema.aaa.zzz.action, 'asd'); // Ok: Fails, because AAA/ZZZ action's (pointing to bbb/xxx) does not have a content
const res_20 = doSomething(schema.bbb.xxx.action, 'content string'); // Ok, return type is "CCC-ZZZ"
const res_21 = doSomething({ path: "ccc", method: "zzz" }, 'content string'); // Ok, return type is "CCC-ZZZ"
const res_22 = doSomething(schema.bbb.xxx.action); // Ok: Fails, because content is missing
const res_30 = doSomething(schema.ccc.zzz.action); // Ok: return type is "AAA-ZZZ"
const res_31 = doSomething(schema.ccc.zzz.action, 'asd'); // Ok: Fails because CCC/ZZZ action's (pointing to aaa/zzz) does not have a content
Is it possible to not use @ts-ignore
in this case?
My inclination would be to write doSomething()
's call signature as follows:
declare function doSomething<P extends keyof Schema, M extends keyof Schema[P]>
(
a: { path: P, method: M },
...rest: Schema[P][M] extends { content: infer C } ? [C] : []
): Schema[P][M] extends { result: infer R } ? R : never;
The function is naturally generic in two type arguments: P
corresponding to path
and M
corresponding to method
. We constrain the generics so that they can't be invalid.
After the initial parameter, I use a rest parameter of a tuple type which is either one element long of type Schema[P][M]["content"]
if that exists, or zero elements long if it doesn't. This will either require or prohibit a second argument when called, depending on that content
type. Note that I wrote it with an infer
red conditional type; if you try to do a lot of deep indexing directly, TypeScript usually gets confused (see microsoft/TypeScript#21760) so I'm avoiding that.
For the return type I do the same thing with Schema[P][M]["result"]
.
Let's test it out:
doSomething({ path: '', method: '' }); // error, expected 2 args
const res_01 = doSomething({ path: 'aaa', method: 'xxx' });
// const res_01: "AAA-XXX"
const res_10 = doSomething({ path: "bbb", method: "xxx" });
// const res_10: "BBB-XXX"
doSomething(schema.aaa.zzz.action, 'asd'); // error, expected 1 arg
const res_20 = doSomething(schema.bbb.xxx.action, 'content string');
// const res_20: "CCC-ZZZ"
const res_21 = doSomething({ path: "ccc", method: "zzz" }, 'content string');
// const res_21: "CCC-ZZZ"
doSomething(schema.bbb.xxx.action); // error, expected 2 args
const res_30 = doSomething(schema.ccc.zzz.action);
// const res_30: "AAA-ZZZ"
doSomething(schema.ccc.zzz.action, 'asd'); // error, expected 1 arg
Looks good. All your tests behave as desired.