I’m trying to specify a TypeScript type for a function that takes an input
and an optional outputType
parameter which determines the return type. If no outputType
is specified the return type matches typeof input
. The return value may also be a Promise
of that type.
For example,
type Decrypter = (options: {
input: string | Buffer | stream.Readable;
outputType?: "string" | "buffer" | "stream" | undefined;
}) => X | Promise<X>;
Where X
would be string
, Buffer
, or stream.Readable
depending on the value of outputType
, or the same type as options["input"]
if outputType
is undefined
.
I feel like there is something that can be done with generics, extends
, and possibly infer
, but I can’t quite get the right incantation!
Any ideas?
Below is an example of a basic implementation:
const decrypter: Decrypter = async ({ input, outputType }) => {
let encrypted: string;
if (typeof input === "string") {
encrypted = input;
outputType ??= "string";
} else if (Buffer.isBuffer(input)) {
encrypted = input.toString("utf8");
outputType ??= "buffer";
} else if (Readable.isReadable(input)) {
const buffers = [];
for await (const buffer of input as Readable) {
buffers.push(buffer);
}
encrypted = Buffer.concat(buffers).toString("utf8");
outputType ??= "stream";
} else {
throw new Error("Invalid input");
}
const decrypted = encrypted.split("").reverse().join("");
switch (outputType) {
case "string":
return decrypted;
case "buffer":
return Buffer.from(decrypted);
case "stream":
return Readable.from(decrypted);
default:
throw new Error("Invalid outputType");
}
};
I’m using Node.js v18.4.0, TypeScript v4.7.4.
CodePudding user response:
The first thing we need is a map which maps from the string literal type to the return type.
type TypeMap = {
string: string
buffer: Buffer
stream: stream.Readable
}
For both input
and output
we add the generic types I
and O
to capture the type of the object passed to the function. We also constrain both generic types to their respective allowed inputs. We add never
as the default type for O
if a type for outputType
was not provided.
type Decrypter = <
I extends TypeMap[keyof TypeMap],
O extends keyof TypeMap = never
>(options: {
input: I;
outputType?: O;
}) => DecryptorReturn<O, I>;
For the return type we check if any type was provided for O
. If yes, we can use the TypeMap
to lookup the type.
type DecryptorReturn<O extends keyof TypeMap, I> = [O] extends [never]
? I | Promise<I>
: TypeMap[O] | Promise<TypeMap[O]>
Now some tests to see if it's working:
const fn: Decrypter = {} as any
const res1 = fn({ input: "abc" })
// ^? const res1: "abc" | Promise<"abc">
const res2 = fn({ input: "abc", outputType: "buffer" })
// ^? const res2: Buffer | Promise<Buffer>
Looks good to me.
When you writing functions with complex generic types, TypeScript will always have problems understanding the logic of your function implementation. For example, you try to assign a string
to outputType
. But since outputType
is of type O
TypeScript will net let you assign anything to it.
So have some short-comings when trying to type the function itself. First of all, I would give input
and outputType
explicit types which are not generic so that you can assign things to them.
(async ({ input, outputType }: {input: TypeMap[keyof TypeMap], outputType: keyof TypeMap}) => { ... }
TypeScript will now have problems with assigning this function to the type Decrypter
because it is not generic. I would use a simple type assertion to silence this error.
const decrypter: Decrypter = (async ({ input, outputType }: {input: TypeMap[keyof TypeMap], outputType: keyof TypeMap}) => {
/* ... */
}) as Decrypter