Home > Software design >  why ConditionalType resolves to different type than expected?
why ConditionalType resolves to different type than expected?

Time:01-09

I wonder why TypeScript resolves conditional type to never if I add a constraint that should guaranty desire type.

Example

Let's say we have written such code

type PrimitiveDataType = string | number | bigint | boolean | symbol | undefined | null;

type ConditionalType<T> = T extends PrimitiveDataType
  ? (v: T) => void
  : never;

abstract class AbstractClass<T> {
  abstract value: T;
  protected conditionalFunctions: Map<ConditionalType<T>, number | undefined> = new Map();
}

class SomeClass<T extends PrimitiveDataType> extends AbstractClass<T> {
  value: T;

  constructor(value: T) {
    super();
    this.value = value;
  }

  someMethod() {
    for (const someFn of this.conditionalFunctions.keys()) {
      someFn(this.value);
    }
  }
}

TS Playground link

In the above code I have created a PrimitiveDataType which is a union of all primitive data types in JavaScript.

Then I have created a ConditionalType<T> that will resolve to some callback only if T is one of PrimitiveDataType.

Then I have created an abstract generic class that have a field which type(ConditionalType<T>) depends on generic value of this class.

In the end, I have crated a SomeClass that extends AbstractClass<T> and add constraints that generic parameter T have to extend PrimitiveDataType and I get this error:

TS2345: Argument of type 'PrimitiveDataType' is not assignable to parameter of type 'never'.   
Type 'undefined' is not assignable to type 'never'.

Conclusion

I thought if in SomeClass T have a constraint that it have to be one on PrimitiveDataType then TypeScript will resolve conditionalFunctions field to be a type of Map<(v: T) => void, number | undefined>. To my surprise TypeScript resolves this type to Map<(v: never) => void, number | undefined> what is not clear for me and I do not know where is a mistake in the way how do I think about this?

Can you explain to me why this work like that, or maybe it is bug in TypeScript compiler?

Observations

If I leave only one, type in PrimitiveDataType then everything works okay but for more than one I am getting an error

Edit 1

If my conditional type has more than two possible return types, then distributive [T] doesn't work and gives such error:

Expected 2 arguments, but got 1.

Looks like now TS resolves this type to type mapped for Array. I totally don't understand it now

Example

type PrimitiveDataType = string | number | bigint | boolean | symbol | undefined | null;

type ConditionalType<T> = [T] extends [PrimitiveDataType]
  ? (v: T) => void
  : T extends Array<unknown>
  ? (v: T, t: number) => void
  : never;

abstract class AbstractClass<T> {
  abstract value: T;
  protected conditionalFunctions: Map<ConditionalType<T>, number | undefined> = new Map();
}

class SomeClass<T extends PrimitiveDataType> extends AbstractClass<T> {
  value: T;

  constructor(value: T) {
    super();
    this.value = value;
  }

  someMethod() {
    for (const someFn of this.conditionalFunctions.keys()) {
      someFn(this.value);
    }
  }
}

TS Playground - EDIT 1

In my opinion, this should work the same as in this example:

type PrimitiveDataType = string | number | bigint | boolean | symbol | undefined | null;

type ConditionalType<T> = [T] extends [PrimitiveDataType]
  ? (v: T) => void
  : T extends Array<unknown>
  ? (v: T, t: number) => void
  : never;

const x: PrimitiveDataType = 12;
const y: ConditionalType<typeof x> = (param: number) => undefined;

TS Playground - variables example

There is no problem and ConditionalType<typeof x> is resolved to (v: number) => void so what I would expect.

CodePudding user response:

Your generic type ConditionalType<T> produces a distributive type when given a union.

For example when given the union string | number it results in the following type

type Foo = ConditionalType<string | number>
// ((v: string) => void) | ((v: number) => void)

Wrap either side of the condition in square brackets to avoid this

type ConditionalType<T> = [T] extends [PrimitiveDataType]
  ? (v: T) => void
  : never;

The above now generates the following, non-distributive, type which should work for your abstract class

type Foo = ConditionalType<string | number>
// (v: string | number) => void

Playground

CodePudding user response:

I'm quite junior on typescript, I've been using it for more or less a year and a half, but I'll still try to help you and give you a possible explanation to the error you encountered (at least what I think is the explanation), and possible ways to fix it.

/* =============== EXPLANATION =============== */

One reason, and the one that makes the most sense to me here, could be that the type narrowing wasn't sufficient. Why?

In the code you provided you defined the type ConditionalType<T> to be a function that returns void when T is a PrimitiveDataType and never when T is not a PrimitiveDataType. This is logically correct, however the way you are using this function type could be not the best. This function type purpose is to narrow the type of T type and should be used as a type guard. When you declare the class SomeClass however the T type is extending the raw PrimitiveDataType and not the narrowed type, so in the for loop you are checking if a raw type PrimitiveDataType is a valid key of the narrowed type version (as you declared in the AbstractClass), but of course never does not exists on the PrimitiveDataType. That's why you should extend T in the class SomeClass declaration as shown in the second fix i provided you below. In that way T will also inlcude never by default. And so value type will be the narrowed version type aka ConditionalType<PrimitiveDataType> and not PrimitiveDataType.

I hope you understood my point (my english still not very good haha).

/* =============== FIXES =============== */

I'm gonna provide you two ways to fix this, with the second one being the most type-safe one.

I think you could fix this in two ways:

The first would be defining the PrimitiveDataType to always be a function type:

type ConditionalType<T> = (v: T) => void;

Of course in the case above maybe ConditionalType is not the right name anymore find a better one. Link to the typescript playground for the case described above -> Typescript playground

/* =============== OR =============== */

Or you can use this other approach where the type parameter T is constrained to be a subtype of the ConditionalType<PrimitiveDataType> type. In this case T extends ConditionalType<PrimitiveDataType> which means that T must be a function type that takes an argument of type PrimitiveDataType and returns void.

type PrimitiveDataType = string | number | bigint | boolean | symbol | undefined | null;

type ConditionalType<T> = T extends PrimitiveDataType
  ? (v: T) => void
  : never;

abstract class AbstractClass<T> {
  abstract value: T;
  protected conditionalFunctions: Map<ConditionalType<T>, number | undefined> = new Map();
}

class SomeClass<T extends ConditionalType<PrimitiveDataType>> extends AbstractClass<T> {
  value: T;

  constructor(value: T) {
    super();
    this.value = value;
  }

  someMethod() {
    for (const someFn of this.conditionalFunctions.keys()) {
        someFn(this.value);        
    }
  }
}

Link to the typescript playground for the second approach -> Typescript playground

/* =============== EDIT 1 =============== */

What about the EDIT 1

The explanation for the error you are encountering when trying to use more than one parameters is pretty much the same, its always related to type narrowing, but the fix is very very simple. You just forgot to make the second parameter optional.

/* =============== FIX EDIT 1 =============== */

type ConditionalType<T> = T extends PrimitiveDataType
  ? (v: T) => void
  : T extends Array<unknown>
  ? (v: T, t?: number) => void
  : never;

Here there is the typescript playground with the full code for the fix of EDIT 1 -> Typescript playground

Please let me know if this was helpful. :)

  • Related