Search code examples
typescript

Can we use a first argument type to determine (with an indexed schema) if we can use another argument, while being fully type-safe


If we have a schema describing paths, nested methods, and then their characteristics:

interface Schema {
  'aaa': {
    'zzz': { result: 'AAA-ZZZ', action: { path: 'bbb', method: 'xxx' } }
    'xxx': { result: 'AAA-XXX', action: { path: 'ccc', method: 'zzz' } }
  };
  'bbb': {
    'xxx': { result: 'BBB-XXX', action: { path: 'ccc', method: 'zzz' } }
  };
  'ccc': {
    'zzz': { result: 'CCC-ZZZ', action: { path: 'aaa', method: 'zzz' }, content: string }
  }
}

The goal is to type a function that will take an action description, and only when necessary use a second argument to get the content.

For example:

doSomething({ path: 'aaa', method: 'xxx' }) // return "AAA-XXX", no second argument.

doSomething({ path: 'aaa', method: 'xxx' }, "content string") // Should fail because this path/method does not use a content.

doSomething({ path: "ccc", method: "zzz" }, "content string") // return "CCC-ZZZ", takes a second argument to specify the content.

doSomething({ path: "ccc", method: "zzz" }) // Should fail because a second argument is missing

I found this answer that helped a lot building something that approach the desired result.

Here is what I have with a more simple schema:

interface Schema {
  'aaa': { result: string };
  'bbb': { result: number };
  'ccc': { result: boolean, content: string };
}

type DoSomethingArgs = {
  [Key in keyof Schema]: [key: Key, ...rest: Schema[Key] extends {
    content: infer InferredContent
  } ? [specificContent: InferredContent] : []]
}[keyof Schema]

function doSomething<
  Args extends DoSomethingArgs,
  Key extends Args[0],
  Out = Schema[Key]['result']
>(...args: Args): Out {

  return undefined as Out; // Not the point.
}


// Calling tests

const res_1 = doSomething("aaa"); // OK, result is string
const res_2 = doSomething("bbb"); // OK, result is number
const res_3 = doSomething("bbb", 'wrong content'); // OK: TS2345: Argument of type string is not assignable to parameter of type never

const res_4 = doSomething("ccc", 123); // OK: TS2345: Argument of type number is not assignable to parameter of type string
const res_5 = doSomething("ccc", 'some content'); // OK, result is boolean

const res_6 = doSomething("ccc"); // OK: Should fail because content is required
//                             ^^^^^
//  TS2345: Argument of type ["ccc"] is not assignable to parameter of type DoSomethingArgs
//    Type ["ccc"] is not assignable to type [key: "ccc", specificContent: string]
//      Source has 1 element(s) but target requires 2

/* All okay \o/, but example is too simple */

I managed to have a result with the desired schema, but the code is using // @ts-ignore:

interface Schema {
  'aaa': {
    'zzz': { result: 'AAA-ZZZ', action: { path: 'bbb', method: 'xxx' } }
    'xxx': { result: 'AAA-XXX', action: { path: 'ccc', method: 'zzz' } }
  };
  'bbb': {
    'xxx': { result: 'BBB-XXX', action: { path: 'ccc', method: 'zzz' } }
  };
  'ccc': {
    'zzz': { result: 'CCC-ZZZ', action: { path: 'aaa', method: 'zzz' }, content: string }
  }
}


type DoSomethingArgs = {
  [Path in keyof Schema]: {
    [Method in keyof Schema[Path]]: [
      out: Schema[Path][Method] extends { result: infer Result } ? Result : never,
      action: {
        path: Path,
        method: Method
      },
      ...rest: Schema[Path][Method] extends { content: infer InferredContent } ? [specificContent: InferredContent]
        : [],
    ]
  }[keyof Schema[Path]]
}[keyof Schema]


function doSomething<
  Args extends DoSomethingArgs extends [unknown, ...infer Rest] ? Rest : never,
  

  // Here is the issue !
  // Without the ts-ignore we have the following errors:
  // TS2536: Type Args[0]["method"] cannot be used to index type Schema[Args[0]["path"]]
  // TS2536: Type "result" cannot be used to index type Schema[Args[0]["path"]][Args[0]["method"]]

  // @ts-ignore
  Out extends Schema [Args[0]["path"]] [Args[0]["method"]] ['result']
>(...args: Args): Out {

  return undefined as Out; // Not the point.
}



// Calling tests

declare const schema: Schema;


const res_00 = doSomething({ path: '', method: '' }); // Ok: Fails because action type is incorrect

const res_01 = doSomething({ path: 'aaa', method: 'xxx' }); // Ok, return type is "AAA-XXX"

const res_10 = doSomething({ path: "bbb", method: "xxx" }); // Ok, return type is "BBB-XXX"
const res_11 = doSomething(schema.aaa.zzz.action, 'asd'); // Ok: Fails, because AAA/ZZZ action's (pointing to bbb/xxx)  does not have a content

const res_20 = doSomething(schema.bbb.xxx.action, 'content string'); // Ok, return type is "CCC-ZZZ"
const res_21 = doSomething({ path: "ccc", method: "zzz" }, 'content string'); // Ok, return type is "CCC-ZZZ"
const res_22 = doSomething(schema.bbb.xxx.action); // Ok: Fails, because content is missing

const res_30 = doSomething(schema.ccc.zzz.action); // Ok: return type is "AAA-ZZZ"
const res_31 = doSomething(schema.ccc.zzz.action, 'asd'); // Ok: Fails because CCC/ZZZ action's (pointing to aaa/zzz) does not have a content

Is it possible to not use @ts-ignore in this case?


Solution

  • My inclination would be to write doSomething()'s call signature as follows:

    declare function doSomething<P extends keyof Schema, M extends keyof Schema[P]>
      (
        a: { path: P, method: M },
        ...rest: Schema[P][M] extends { content: infer C } ? [C] : []
      ): Schema[P][M] extends { result: infer R } ? R : never;
    

    The function is naturally generic in two type arguments: P corresponding to path and M corresponding to method. We constrain the generics so that they can't be invalid.

    After the initial parameter, I use a rest parameter of a tuple type which is either one element long of type Schema[P][M]["content"] if that exists, or zero elements long if it doesn't. This will either require or prohibit a second argument when called, depending on that content type. Note that I wrote it with an inferred conditional type; if you try to do a lot of deep indexing directly, TypeScript usually gets confused (see microsoft/TypeScript#21760) so I'm avoiding that.

    For the return type I do the same thing with Schema[P][M]["result"].


    Let's test it out:

    doSomething({ path: '', method: '' }); // error, expected 2 args
    const res_01 = doSomething({ path: 'aaa', method: 'xxx' });
    // const res_01: "AAA-XXX"
    
    const res_10 = doSomething({ path: "bbb", method: "xxx" });
    // const res_10: "BBB-XXX"
    doSomething(schema.aaa.zzz.action, 'asd'); // error, expected 1 arg
    
    const res_20 = doSomething(schema.bbb.xxx.action, 'content string');
    // const res_20: "CCC-ZZZ"
    const res_21 = doSomething({ path: "ccc", method: "zzz" }, 'content string');
    // const res_21: "CCC-ZZZ"
    doSomething(schema.bbb.xxx.action); // error, expected 2 args
    
    const res_30 = doSomething(schema.ccc.zzz.action);
    // const res_30: "AAA-ZZZ"
    doSomething(schema.ccc.zzz.action, 'asd'); // error, expected 1 arg
    

    Looks good. All your tests behave as desired.

    Playground link to code