3

Given the following types

type Tool = 'network_query' | 'cluster'
type Network = 'one' | 'two' | 'three'

class QueryOneParams {...}
class QueryTwoParams {...}
class QueryThreeParams {...}
class ClusterParams {...}

I'm trying to define a mapping between combinations of Tool and Network so that I could write something like this:

const queryParamsLookup = {
    'one': QueryOneParams,
    'two': QueryTwoParams,
    'three': QueryThreeParams
}

type Job<Tool, Network> = {
    id: string,
    params: JobParams<Tool, Network>
}

where

  • JobParams<'network_query', 'one'> resolves to QueryOneParams
  • JobParams<'network_query', 'two'> resolves to QueryTwoParams
  • JobParams<'network_query', 'three'> resolves to QueryThreeParams
  • JobParams<'cluster'> resolves to ClusterParams
  • JobParams<'cluster', 'one'>, JobParams<'cluster', 'two'> and JobParams<'cluster', 'three'> are invalid

That would require me to somehow define that the second generic parameter 'one' | 'two' | 'three' is only used and required, if the first parameter is 'network_query'. Afaik, Typescript does not support optionally defined generic parameters based on the type of another parameter.

Is that correct? I'd love to be wrong here :)

As an alternative, I have defined a helper type like so:

type NetworkQueryJobType = {
    [N in keyof typeof queryParamsLookup]: ['network_query', N]
}[keyof typeof queryParamsLookup]
// ['network_query', 'one'] | ['network_query', 'two'] | ['network_query', 'three']

type JobType = NetworkQueryJobType | ['cluster']
// ['network_query', 'one'] | ['network_query', 'two'] | ['network_query', 'three'] | ['cluster']

and changed the definition of Job to

type Job<JobType> = {
    id: string,
    params: JobParams<JobType>
}

with this approach, I'm having trouble getting type inference to work properly in the mapper type JobParams:

type JobParams<T extends JobType> = T extends ['network_query', infer N] ?
typeof queryParamsLookup[N] // Error: N is not compatible with 'one' | 'two' | 'three'
: ClusterParams

I can work around the type inference issue with:

type JobParams<T extends JobType> = T extends ['network_query', infer N] ?
N extends Network ?
    typeof queryParamsLookup[N] // No error
    : ClusterParams
: ClusterParams

All of that however still leaves me with poor autocomplete performance when typing, for example:

const params: JobParams<['

VSCode will not suggest 'cluster' | 'network_query'

All in all, I feel like fighting a losing battle here. Am I doing something fundamentally wrong?

Playground Link

3
  • 1
    Aren't you using type QueryOneParams as an expression in queryParamsLookup? Commented Nov 27, 2021 at 10:57
  • 1
    Yes you're absolutely right! My intention was to abstract away some of my implementation detail. In reality, QueryOneParams and friends are classes. I'll change it in the question. Thank's for catching this :) Commented Nov 27, 2021 at 10:59
  • I've added a playground link for you (it's too long to fit in a comment). Your Job type is erroring. Please fix it and update the link in the question. Commented Nov 27, 2021 at 11:15

1 Answer 1

2

You can use generic parameter defaults and a neutral type, such as null to match the provided behavior.

I have made some assumptions about your code, please correct me if there is something I interpreted incorrectly.

Firstly, queryParamsLookup is a type QueryParamsLookup:

type QueryParamsLookup = {
    'one': QueryOneParams,
    'two': QueryTwoParams,
    'three': QueryThreeParams
}

defining the object as it is defined in the question will resolve the types to the types of provided class constructors, which is probably not what was intended as that would be quite rare.

The JobParams type can then be defined as:

type JobParams<T extends Tool, N extends T extends "network_query" ? Network : null = T extends "network_query" ? Network : null> =
    N extends Network ? QueryParamsLookup[N] : ClusterParams

Which would fulfill the following requirements:

// valid cases
const one: JobParams<"network_query", "one"> = new QueryOneParams()
const two: JobParams<"network_query", "two"> = new QueryTwoParams()
const three: JobParams<"network_query", "three"> = new QueryThreeParams()
const cluster: JobParams<"cluster"> = new ClusterParams()

// invalid cases
const inv1: JobParams<"cluster", "one"> = new QueryOneParams()
const inv2: JobParams<"cluster", "two"> = new QueryTwoParams()
const inv3: JobParams<"cluster", "three"> = new QueryThreeParams()

This definition of JobParams is pessimistic when a union is provided as its first argument, meaning it will treat the union as if it was not network_query. This behaviour can be inverted by flipping the ternary operators.

A link to a playground with the solution.

While this is perfectly possible to do with a tuple generic as well, it is at quite excessive in my opinion and not very intuitive.

Sign up to request clarification or add additional context in comments.

5 Comments

Looks good. Two open questions on my part: - Is there some way to encapsulate the conditions in JobParams, so that I can use them in the Job type from my question without having to type them out twice? - JobParams<'network_query'> is now also a valid definition (maps to QueryOneParams | QueryTwoParams | QueryThreeParams), this is unexpected but actually quite convenient. However, is there a way to have JobParams<'network_query'> raise an error, because the second parameter is missing?
Question #1: I suppose a type alias might help but that is probably pushing the type system beyond its limits. I couldn't find a way to make that work in the current version.
Question #2: Yes there definitely is a way to do that, fallback to never when only "network_query" is the first parameter, like so: type JobParams<T extends Tool, N extends T extends "network_query" ? Network : null = T extends "network_query" ? never : null> = N extends Network ? QueryParamsLookup[N] : ClusterParams
Question #2 playground, unfortunately quite stripped down, since there is a comment char limit
Very nice. Thank you for all the effort and very good explanations :)

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.