Search code examples
typescripttypescript-generics

Assert two string unions are equal with rich error if they are not


I'm working on some type guards for an API framework and want to connect the path parameters (a string) to a validation object.

What I want:

My validation object looks like this:

const params = {
  firstArg: someValidationFunction,
  secondArg: someValidationFunction,
}

Given the following strings, I want the following results:

  1. /api/some/path/{firstArg}/{secondArg} -> OK (both arguments appear in the string by the right name)
  2. /api/some/path/{secondArg}/{firstArg} -> OK (the order of arguments cannot be enforced)
  3. /api/some/path/{someOtherArg} -> Not OK (both args are missing and there is an unexpected arg found)
  4. /api/some/path/{firstArg}/{secondArg}/{someOtherArg} -> Not OK (there is an unexpected arg found)
  5. /api/some/path/{firstArg}/secondArg -> Not OK (second arg is missing since it doesn't have the curly braces)

What I have so far:

Following this blog I have this type helper:

type ExtractPathParams<Path> = Path extends `${infer Segment}/${infer Rest}`
  ? ExtractParam<Segment, ExtractPathParams<Rest>>
  : ExtractParam<Path, {}>;

type ExtractParam<Path, NextPart> = Path extends `{${infer Param}}`
  ? Record<Param, any> & NextPart
  : NextPart;

Which gives me the arguments found in the path, and I've been trying to take the keys from that and the keys from the validation object and expect them to be the same by doing (based on this post:

function assert<T extends never>() {}
type Equal<A, B> = Exclude<A, B> | Exclude<B, A>;

type ValidationKeys = keyof params;
const one = '/api/some/path/{firstArg}/{secondArg}';
const two = '/api/some/path/{secondArg}/{firstArg}';
const three = '/api/some/path/{someOtherArg}';
const four = '/api/some/path/{firstArg}/{secondArg}/{someOtherArg}';
const five = '/api/some/path/{firstArg}/secondArg';

assert<Equal<keyof ExtractPathParams<typeof one>, ValidationKeys>>();
assert<Equal<keyof ExtractPathParams<typeof two>, ValidationKeys>>();
// @ts-expect-error
assert<Equal<keyof ExtractPathParams<typeof three>, ValidationKeys>>();
// @ts-expect-error
assert<Equal<keyof ExtractPathParams<typeof four>, ValidationKeys>>();
// @ts-expect-error
assert<Equal<keyof ExtractPathParams<typeof five>, ValidationKeys>>();

This works but I would really prefer if it could produce a type error stating which keys are missing in the path based on which keys appear in ValidationKeys (rather than saying that string is not assignable to/doesn't satisfy the constraint never).

Is that possible?


Solution

  • You could write an assertEqual() function that compares the two type arguments to each other, instead of using Exclude which will throw away any information about the original type arguments:

    function assertEqual<T extends U, U extends V, V = T>() { }
    

    This performs a mutual constraint where T extends U and U extends T. Of course you can't actually write that because it's circular. Instead I've got T extends U and U extends V and then give V a default type argument of T. This is technically not circular (or at least not detected as such) because you can specify V to whatever you want. But if you call it without specifying V, then you'll T.

    That gives you your desired behavior:

    assertEqual<keyof ExtractPathParams<typeof one>, ValidationKeys>(); // okay
    assertEqual<keyof ExtractPathParams<typeof two>, ValidationKeys>(); // okay
    assertEqual<keyof ExtractPathParams<typeof three>, ValidationKeys>(); // error!
    //          ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    // Type '"someOtherArg"' does not satisfy the constraint '"secondArg" | "firstArg"'.
    assertEqual<keyof ExtractPathParams<typeof four>, ValidationKeys>(); // error!
    //          ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    // Type '"secondArg" | "firstArg" | "someOtherArg"' does not satisfy 
    // the constraint '"secondArg" | "firstArg"'.
    assertEqual<keyof ExtractPathParams<typeof five>, ValidationKeys>(); // error!
    //                                                ~~~~~~~~~~~~~~
    // Type '"secondArg" | "firstArg"' does not satisfy the constraint '"firstArg"'.
    

    Playground link to code