Jazcash
Jazcash

Reputation: 3312

Given a class type, infer typeof other parameters in Typescript

I'm trying to make a function that has 2 parameters, where the type of the 2nd parameter is based on the type of the 1st. I can see several threads that this question might be a duplicate of but the typing syntax can get so complex that I've been fiddling with this for a while now and can't get it right.

class Mammal { }
class Bird { }

function stuff(classType: typeof Mammal | typeof Bird, arg: number or string depends on first param) {
    if (classType extends Mammal) {
        console.log(arg * 5);
    } else {
        console.log(arg.split(" "))
    }
}

stuff(Mammal, 5);  // want this to be valid

stuff(Bird, "Hello world"); // want this to be valid

stuff(Bird, 5); // want this to be invalid

Playground link

Upvotes: 0

Views: 326

Answers (1)

Maciej Sikora
Maciej Sikora

Reputation: 20162

class Mammal {
    static type = 'mammal' as const
 }
class Bird {
    static type = 'bird' as const
}

function stuff<
M extends typeof Mammal | typeof Bird, 
A extends (M extends (typeof Mammal) ? number : string)>
(classType: M, arg: A) {
    if (classType.type === 'mammal') {
        console.log((arg as number) * 5);
    } else {
        console.log((arg as string).split(" "))
    }
}

stuff(Mammal, 5); // ok
stuff(Mammal, 'a'); // error

stuff(Bird, "Hello world"); // ok
stuff(Bird, 1); // error

TS is structural typed language, that means that if you have two classes which have the same definition, they are just the same for TS, as there is no structural difference between them. Below proove of this statement:

class Mammal {}
class Bird {}
type MammalIsBird 
  = Mammal extends Bird 
  ? Bird extends Mammal 
  ? true 
  : false 
  : false // evaluates to true, Mammal and Bird are equal

That is the reason why in order to distinguish both Mammal and Bird we need to create some static property to have the difference.

Another thing is that when we ask about this property in for example Mammal there will be none, as Mammal has all non-static properties, we need to use typeof Mammal in order to have the interface with static ones. That is why in the implementation I am using typeof M and not M.

Few important informations:

  • M extends typeof Mammal | typeof Bird means we allow or class Mammal or class Bird, if I would say just Mammal | Bird it would mean I want to get instances of the class not a class itself
  • A extends (M extends (typeof Mammal) ? number : string) - conditional type, if we get Mammal type as M we have second argument number if not it is string
  • classType.type === 'mammal' thanks of having static property we can use it in condition
  • (arg as number) type assertion needs to be done, as discriminant for first property doesn't work for the second one.

You can avoid type assertion by working with one object/array type of arguments. But I don't recommend this approach, nevertheless here you go:

type MammalArgs = [typeof Mammal, number]
type BirdsArgs = [typeof Bird, string];

const isMammalClass = (x: MammalArgs | BirdsArgs): x is MammalArgs => x[0].type === 'mammal'
const isBirdArgs = (x: MammalArgs | BirdsArgs): x is BirdsArgs => x[0].type === 'bird'
function stuff<
M extends typeof Mammal | typeof Bird,
A extends (M extends (typeof Mammal) ? MammalArgs : BirdsArgs)>
(...args: A) {
    if (isMammalClass(args)) {
        console.log(args[1] * 5); // no type assertion
    }
    if (isBirdArgs(args)) {
        console.log(args[1].split(" ")); // no type assertion
    }
}

What I have done above is joining our arguments into one type [typeof Mammal, number] | [typeof Bird, string] in order to achieve relation between discriminant in first argument with the second. Having both in one type allows on such relation. The issue is that I needed to create two type guards, and also needed to use array directly, as any destructuring would broke our type narrowing. I would choose the first approach with type assertion.

Upvotes: 1

Related Questions