27

Let's say I have a generic interface like the following:

interface Transform<ArgType> {
    transformer: (input: string, arg: ArgType) => string;
    arg: ArgType;
}

And then I want to apply an array of these Transform to a string. How do I define this array of Transform such that it validates that <ArgType> is equivalent in both Transform.transformer and Transform.arg? I'd like to write something like this:

function append(input: string, arg: string): string {
    return input.concat(arg);
}

function repeat(input: string, arg: number): string {
    return input.repeat(arg);
}

const transforms = [
    {
        transformer: append,
        arg: " END"
    },
    {
        transformer: repeat,
        arg: 4
    },
];

function applyTransforms(input: string, transforms: \*what type goes here?*\): string {
    for (const transform of transforms) {
        input = transform.transformer(input, transform.arg);
    }

    return input;
}

In this example, what type do I define const transforms as in order for the type system to validate that each item in the array satisfies the generic Transform<ArgType> interface?

user1084447
  • 839
  • 3
  • 9
  • 21
  • I don't think you can do this in 100% type safe way. `Transform[]` will work but it will not be 100% type safe. You may find this useful: https://stackoverflow.com/questions/51815782/typescript-array-of-different-generic-types/51816182#51816182 – Titian Cernicova-Dragomir Aug 16 '18 at 15:50
  • It's crazy how complicated this needs to be in Typescript. – Robert Jun 10 '20 at 11:32

2 Answers2

24

(Using TS 3.0 in the following)

If TypeScript directly supported existential types, I'd tell you to use them. An existential type means something like "all I know is that the type exists, but I don't know or care what it is." Then your transforms parameter have a type like Array< exists A. Transform<A> >, meaning "an array of things that are Transform<A> for some A". There is a suggestion to allow these types in the language, but few languages support this so who knows.

You could "give up" and just use Array<Transform<any>>, which will work but fail to catch inconsistent cases like this:

applyTransforms("hey", [{transformer: repeat, arg: "oops"}]); // no error

But as you said you're looking to enforce consistency, even in the absence of existential types. Luckily, there are workarounds, with varying levels of complexity. Here's one:


Let's declare a type function which takes a T, and if it a Transform<A> for some A, it returns unknown (the new top type which matches every value... so unknown & T is equal to T for all T), otherwise it returns never (the bottom type which matches no value... so never & T is equal to never for all T):

type VerifyTransform<T> = unknown extends
  (T extends { transformer: (input: string, arg: infer A) => string } ?
    T extends { arg: A } ? never : unknown : unknown
  ) ? never : unknown

It uses conditional types to calculate that. The idea is that it looks at transformer to figure out A, and then makes sure that arg is compatible with that A.

Now we can type applyTransforms as a generic function which only accepts a transforms parameter which matches an array whose elements of type T match VerifyTransform<T>:

function applyTransforms<T extends Transform<any>>(
  input: string, 
  transforms: Array<T> & VerifyTransform<T>
): string {
  for (const transform of transforms) {
    input = transform.transformer(input, transform.arg);
  }
  return input;
}

Here we see it working:

applyTransforms("hey", transforms); // okay

If you pass in something inconsistent, you get an error:

applyTransforms("hey", [{transformer: repeat, arg: "oops"}]); // error

The error isn't particularly illuminating: "[ts] Argument of type '{ transformer: (input: string, arg: number) => string; arg: string; }[]' is not assignable to parameter of type 'never'." but at least it's an error.


Or, you could realize that if all you're doing is passing arg to transformer, you can make your existential-like SomeTransform type like this:

interface SomeTransform {
  transformerWithArg: (input: string) => string;
}

and make a SomeTransform from any Transform<A> you want:

const makeSome = <A>(transform: Transform<A>): SomeTransform => ({
  transformerWithArg: (input: string) => transform.transformer(input, transform.arg)
});

And then accept an array of SomeTransform instead:

function applySomeTransforms(input: string, transforms: SomeTransform[]): string {
  for (const someTransform of transforms) {
    input = someTransform.transformerWithArg(input);
  }

  return input;
}

See if it works:

const someTransforms = [
  makeSome({
    transformer: append,
    arg: " END"
  }),
  makeSome({
    transformer: repeat,
    arg: 4
  }),
];

applySomeTransforms("h", someTransforms);

And if you try to do it inconsistently:

makeSome({transformer: repeat, arg: "oops"}); // error

you get an error which is more reasonable: "Types of parameters 'arg' and 'arg' are incompatible. Type 'string' is not assignable to type 'number'."


Okay, hope that helps. Good luck.

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • What is the purpose of `makeSome` here ? are you just creating a new object of type `SomeTransform` from `Transform` ? in that case the type of `someTransforms` should be `SomeTransform[]` right? – Niladri Aug 16 '18 at 16:01
  • 1
    I think you can simplify `VerifyTransform` to just `type VerifyTransform = (T extends Transform ? unknown : never) ` it seems to work as expected. And although `never` is the logical way to ensure incompatibility, maybe a string literal type will give nicer errors: `type VerifyTransform = (T extends Transform ? unknown : "INconsistent args to Transform") ` – Titian Cernicova-Dragomir Aug 16 '18 at 16:08
  • 2
    @TitianCernicova-Dragomir: that simplification might work... but I've run into the problem where `infer A` ends up inferring an intersection of types (hence the two-step process), or where any one of the types in a union works making the whole thing work, whereas we need all the types to work (hence the never-to-unknown-and-back-again stuff to make one failure give all failures). I want this to fail: `applyTransforms("hey", [{transformer: repeat, arg: 1}, {transformer: repeat, arg: "oops"}]);` – jcalz Aug 16 '18 at 16:14
  • @Niladri, yes, that's what I'm doing. – jcalz Aug 16 '18 at 16:15
  • Clever use of a distributive conditional type to validate that the inferred type of `transforms` is of the form `Transform | Transform | ...`! Of course, there are some limitations with this approach. It doesn't give you a single type you can write down for `Array< exists A. Transform >`, so AFAIK you don't get automatic type checking of manipulations like appending a transform to an existing array. – Matt McCutchen Aug 16 '18 at 16:17
  • What is the best way to get that type of knowledge? I'd like to focus on advanced typescript, but the documentation itself doesn't explain very deep. Any ideas? – PEZO Feb 13 '21 at 13:43
12

You can do this using the generic tuple rest parameters (added in TS 3.0).

type TransformRest<T extends any[]> = {
   [P in keyof T]: T[P] extends T[number] ? Transform<T[P]> : never
}

function applyTransforms<T extends any[]>(input: string, ...transforms: TransformRest<T>): string {
   for (const transform of transforms) {
      input = transform.transformer(input, transform.arg);
   }

   return input;
}

// Makes a tuple from it's arguments, otherwise typescript always types as array
function tuplify<TS extends any[]>(...args: TS) {
   return args;
}

// Use like this:
const transforms = tuplify(
   {
      transformer: append,
      arg: " END"
   },
   {
      transformer: repeat,
      arg: 4
   },
);

//And call apply transforms like this:
applyTransforms("string", ...transforms)

//or like this:
applyTransforms("string", transform1, transform2)

Explanation

Typescript has really powerful type inference, but usually chooses the loosest types it can. In this case you need to force it to think of your transforms as a Tuple so that each element has it's own type, and then let the inference do the rest.

I did this with mapped types, the one hiccup with this is that Typescript will use all the tuple keys (such as "length"), not just the numeric ones. You just need to force it to only map the numeric ones. Hence the condition: T[P] extends T[number]

Edward Kotarski
  • 594
  • 5
  • 11