Here is the entrypoint to my TypeScript playground which is quite large, but a simplified version seems to be working, so I'm leaving it as is. Most of the types are simple trees of objects, no generics or anything (toward the bottom). And then there is a huge list basically of the imagemagick formats. But this entrypoint function is where everything goes wrong.
export async function convert<I extends ConvertInputFormat>(
source: Convert<I>,
) {
if (
useConvertFontWithFontForge(
source.input.format,
source.output.format,
)
) {
return await convertFontWithFontForgeNode(source)
}
if (
useConvertImageWithImageMagick(
source.input.format,
source.output.format,
)
) {
// HACKING to see if it works even.
function is(x: string): asserts x is ImageMagickInputFormat {}
function is2(x: string): asserts x is ImageMagickOutputFormat {}
is(source.input.format)
is2(source.output.format)
return await convertImageWithImageMagickNode(source)
}
// 15+ more useX checks and calls out to nested functions with various type signatures.
}
In the two calls:
convertFontWithFontForgeNode(source)
convertImageWithImageMagickNode(source)
...the source
is saying:
Argument of type '{ input: { format: I; }; output: { format: Exclude<ConvertOutputFormat<I>["output"], I>; }; } & (ConvertImageWithImageMagickNodeInput | ConvertFontWithFontForgeNodeInput)' is not assignable to parameter of type 'ConvertFontWithFontForgeNodeInput'.
Type '{ input: { format: I; }; output: { format: Exclude<ConvertOutputFormat<I>["output"], I>; }; } & ConvertImageWithImageMagickNodeRemoteInput' is not assignable to type 'ConvertFontWithFontForgeNodeInput'.
Type '{ input: { format: I; }; output: { format: Exclude<ConvertOutputFormat<I>["output"], I>; }; } & ConvertImageWithImageMagickNodeRemoteInput' is not assignable to type 'ConvertFontWithFontForgeNodeRemoteInput'.
The types of 'input.format' are incompatible between these types.
Type 'I & ("3fr" | "3g2" | "3gp" | "aai" | "ai" | "apng" | "art" | "arw" | "avi" | "avif" | "avs" | "bayer" | "bayera" | "bgr" | "bgra" | "bgro" | "bmp" | "bmp2" | "bmp3" | "cal" | "cals" | ... 206 more ... | "yuv")' is not assignable to type '"bmp" | "otf" | "svg" | "ttf" | "woff2" | "woff" | "eot"'.
Type 'I & "3fr"' is not assignable to type '"bmp" | "otf" | "svg" | "ttf" | "woff2" | "woff" | "eot"'.(2345)
(parameter) source: {
input: {
format: I;
};
output: {
format: Exclude<ConvertOutputFormat<I>["output"], I>;
};
} & (ConvertImageWithImageMagickNodeInput | ConvertFontWithFontForgeNodeInput)
Notice it's saying ImageMagick is not assignable to type FontForge
. I don't see why it's trying to do that, trying to correlate the image block with the font block... I explicitly tried to keep them separate.
If you remove 99% of the enum values for the format
(stored in the FontFormat
type and such), then it seems to work. Only thing I can think of is that the svg
and bmp
type values are in both FontFormat
and ImageMagickInputFormat
/ImageMagickOutputFormat
, so maybe that's causing a problem? I've tried a ton of things over the past few days, but still haven't been able to get this to work.
What I want to do is have it so I can do this:
// SUCCESS (because font can transform to SVG or WOFF)
convert({
input: { format: "ttf", file: { path: "my.ttf" } },
output: { format: "svg", file: { path: "my.svg" } },
});
convert({
input: { format: "ttf", file: { path: "my.ttf" } },
output: { format: "woff", file: { path: "my.woff" } },
});
// FAILURE (because FontFormat doesn't transform to JPG)
convert({
input: { format: "ttf", file: { path: "my.ttf" } },
output: { format: "jpg", file: { path: "my.jpg" } },
});
That, and getting it so it passes the types down to the nested functions, and gets rid of those errors like shown above. Finally, you'll notice that there are more than just the input
/output
properties going into the convert
function, you can have all kinds of different properties per call type.
convert({
input: { format: "png", file: { path: "my.png" } },
output: { format: "jpg", file: { path: "my.jpg" } },
colorSpace: '...',
});
That I tried to do with the & ConvertOutputFormat<I>['extend']
at the bottom of this, but that's where things seem to break.
export type Convert<I extends ConvertInputFormat> = {
input: {
format: I
}
output: {
format: Exclude<ConvertOutputFormat<I>['output'], I>
}
} & ConvertOutputFormat<I>['extend']
I'm trying to use the input.format
and output.format
together as keys in a distributed union sort of configuration. That scopes what properties and such are available. But it's not working.
I use zod
in the nested functions to parse the inputs, so everything inside convertFontWithFontForgeNode
and the like are types that are directly mapped to simple zod
schemas. So those types are solid I think. The not-solid type is this convert
function type, which should somehow wrap all the zod functions.
For completeness, my zod schemas are like:
export const ConvertFontWithFontForgeNodeInputModel: z.ZodType<ConvertFontWithFontForgeNodeInput> =
z.union([
z.lazy(() => ConvertFontWithFontForgeNodeRemoteInputModel),
z.lazy(() => ConvertFontWithFontForgeNodeLocalExternalInputModel),
z.lazy(() => ConvertFontWithFontForgeNodeLocalInternalInputModel),
])
export const ConvertFontWithFontForgeNodeRemoteInputModel: z.ZodType<ConvertFontWithFontForgeNodeRemoteInput> =
z.object({
handle: z.literal('remote'),
input: z.object({
format: z.lazy(() => FontFormatModel),
file: z.union([
z.lazy(() => FileInputPathModel),
z.lazy(() => FileContentWithSha256Model),
]),
}),
output: z.object({
format: z.lazy(() => FontFormatModel),
file: z.optional(z.lazy(() => LocalPathModel)),
}),
pathScope: z.optional(z.string()),
})
So they directly map to the simple typescript types.
This can be achieved by using conditional types instead of union types. Your playground was too complex to fix completely, but it should be something like this:
const FONT_FORMAT = ['ttf', 'woff', 'svg'] as const
type FontFormat = (typeof FONT_FORMAT)[number]
const IMAGE_MAGICK_INPUT_FORMAT = ['jpg'] as const
type ImageMagickInputFormat = (typeof IMAGE_MAGICK_INPUT_FORMAT)[number]
const IMAGE_MAGICK_OUTPUT_FORMAT = ['jpg'] as const
type ImageMagickOutputFormat = (typeof IMAGE_MAGICK_OUTPUT_FORMAT)[number]
async function convert<InputFormat extends FontFormat | ImageMagickInputFormat>(source: {
input: {
format: InputFormat,
file: { path: string }
},
output: {
format: InputFormat extends FontFormat ? FontFormat : ImageMagickOutputFormat,
file: { path: string }
}
}) {
// TODO: implement
}
This makes your example input work as expected in the TS playground:
Although it works, I would advice to keep separate functionalities in separate functions instead of combining them in a single function. This will dramatically simplify the types.
A general mutually exclusive union of types like you want does not exist in Typescript. The proposal to add it to the language was never implemented. There was a successful workaround for types of one level deep and there exists a package that adds a type XOR<A, B, C, D, E, F, ...>
. However, none of those work on “complex” nested types as far as I see.