Background
I've been working on a library (see convert and format entrypoint functions if curious, not necessary though, the TS playground should be enough) and have asked a few questions (here and on Reddit, for the less clear-cut-answerable questions), which led me almost to solutions to 2 problems:
- Having types for a function which maps various inputs each to a specific output based on the
formatproperty. This is for theformatfunction (calledcompilein that link). - Having types for a function which takes an
input.formatproperty, and the correspondingoutput.formatproperty is scoped to theinput.formatproperty. This is for theconvertfunction, still working on a solution to that.
Goal
Now I'm trying to compose/combine these two functions (as well as other similar functions) into a single function called call that will take JSON with a key for the action (convert/format/etc.). I have the TypeScript playground here, it's a bit too long to post. Essentially it boils down to what is at the start of the playground example:
call({
action: Call.Convert,
input: {
format: 'png',
file: {
path: 'foo',
},
},
output: {
format: 'jpg',
file: {
path: 'bar.jpg',
},
},
})
export async function call<
T extends Call,
C extends ConvertInputFormat,
F extends FormatPrettier,
>(input: CallInput<T, C, F>) {
switch (input.action) {
case 'convert':
return await convert(input as Convert<C>)
case 'format':
return await format(input as FormatPrettierInput<F>)
}
}
TypeScript Error I'm Getting
It throws a TypeScript error at output.format == 'jpg'. How do I get it to work in the composed call function, combining all these relatively complex types from the nested function calls? It's not absolutely necessary this works, I can just pass an any to it lol, but it would be nice to see how one could make this work.
If I tinker around with the TypeScript playground, I can't seem to find a way to compose the complex nested functions without some sort of TypeScript error happening. I can get it to work fine in plain JavaScript, just add a few dynamic/runtime checks here and there. But getting this to work with TypeScript seems almost impossible. If it is impossible, please answer with that, with an explanation of why.
Basic Expectations
At a high level, I expect to be able to compose these Call.Convert and Call.Format functions, each of which is itself a complex function calling further into nested functions, into a higher order function. This way, I can have a single function called task.call(input), where input can be anything typed to any of the action types.
In addition, please note that each of these action types takes in (1) a similar set of input parameters in some cases (like input.file.path and input.format), but also (2) they take in different/extended parameters based on the input keys. Let me elaborate on that a little.
This function call's inputs are first typed based on the action property (determined: Call.Convert), then the input.format property (determined: png), then finally the output.format property (any extra properties that are unique to converting a PNG to a JPG, such as what to do with the transparent color, etc..).
call({
action: Call.Convert,
input: {
format: 'png',
file: {
path: 'foo',
},
},
output: {
format: 'jpg',
file: {
path: 'bar.jpg',
},
},
transparentBecomes: '#ff0000'
})
So it's like a multi-property discriminated union type, if there is such a thing.
- Several properties on the
inputare used to scope the type on the rest of theinputprops. - Based on that scope, there can be extra
inputproperties specific to that scope. - Then the
outputof the function call is scoped to whatever the final input properties scope was...
Expectations: API Examples
Here are some examples of what I would expect from the API:
// NO ERROR: There IS a png -> jpg converter defined here
const output1 = call({
action: Call.Convert,
input: {
format: 'png',
file: {
path: 'foo',
},
},
output: {
format: 'jpg',
file: {
path: 'bar.jpg',
},
},
})
// NO ERROR: this .file.path is valid,
// since we have valid output for this Convert call.
console.log(output1.file.path)
// ERROR: There is no png -> html converter defined here
const output2 = call({
action: Call.Convert,
input: {
format: 'png',
file: {
path: 'foo',
},
},
output: {
format: 'html',
file: {
path: 'bar.html',
},
},
})
// ERROR: this .file.path is NOT valid,
// ideally it would be typed as `never`.
console.log(output2.file.path)
// NO ERROR: There is an HTML formatter defined!
call({
action: Call.Format,
input: {
format: 'html',
file: {
path: 'foo.html',
},
},
})
// ERROR: There is NOT a "bar" formatter defined...
call({
action: Call.Format,
input: {
format: 'bar',
file: {
path: 'foo.bar',
},
},
})
Future API
Solving for all these would make the question even bigger than it already is.
In the future I will have an action for each of these:
Call.Archive: Takes as input the input directory, and outputs a.zipor similar archive file.Call.Generate: Takes as input analgorithmfor a hash, and generates that hash given sometext.- Many others (40+ other
actiontypes)...
So the call function should be composed of many similar things to the Call.Convert and Call.Format functions underneath.
Question
How can I build such a tree of composed discriminating union types, which include type generics and such? I have spent weeks on this problem, tinkering this way and that, but haven't reached a working solution and am currently resorting to just using any, like this:
call({
action: Call.Convert,
input: {
format: 'png',
file: {
path: 'foo',
},
},
output: {
format: 'jpg',
file: {
path: 'bar.jpg',
},
},
transparentBecomes: '#ff0000'
} as any)
But I would like for both (1) the input to be typed, and (2) the output to be typed based on the input. The output to the function call.
I feel like some advanced uses of TypeScript in the wild have solved this problem, I just don't know how. For example, the zod library is pretty advanced in the types it creates, so perhaps that would implement something like this internally. But I haven't been able to successfully use "inspiration" sources like this to solve this problem yet, unfortunately.
Update 1
Added a follow-up discussion and linked TS Playground which is similar in spirit to the problem of this specific question, but might shed some light on how to solve this current problem from a different angle.