Home > database >  Typescript Syntax for Type Inference based on kind
Typescript Syntax for Type Inference based on kind

Time:10-06

I have an issue writing proper typescript syntax for strict infering where:

  1. compiler properly reports on missing switch/case option
  2. returned value matches input kind by type
type KindA = {kind:'a'};
type KindB = {kind:'b'};
type KindC = {kind:'c'};
type AllKinds = KindA | KindB | KindC;

function create<T extends AllKinds>(kind:AllKinds['kind']):T {
  switch(kind) {
    case "a": return {kind:'a'};
    case "b": return {kind:'b'};
    case "c": return {kind:'c'};
  }
}

create("a");

Playground

I wonder if this is possible with the latest Typescript.

With my other approach (i.e. case "a": return {kind:'b'} as T;) the returned value is not type checked to what I need.

CodePudding user response:

It is unsafe to return T in your case.

See why:

type KindA = { kind: 'a' };
type KindB = { kind: 'b' };
type KindC = { kind: 'c' };
type AllKinds = KindA | KindB | KindC;

function create<T extends AllKinds>(kind: AllKinds['kind']): T {
  switch (kind) {
    case "a": return { kind: 'a' };
    case "b": return { kind: 'b' };
    case "c": return { kind: 'c' };
  }
}

const result = create<{ kind: 'a', WAAAT: () => {} }>("a")
result.WAAAT() // compiler but causes an error in runtime

Generic argument should in 90% of cases depend on input value.

See this example:

type KindA = { kind: 'a' };
type KindB = { kind: 'b' };
type KindC = { kind: 'c' };
type AllKinds = KindA | KindB | KindC;


const builder = <Kind extends AllKinds['kind']>(kind: Kind) => ({ kind })

const result = builder("a"); // {kind: 'a' }

Playground

See this answer, this answer and my article for more context

Do I get it right that witch current Typescript, one can not have both ?

The problem is not in switch statement but rather in explicit return type. Return type can't depend on a generic value which is not binded with function arguments.

In fact, it is possible to achieve waht you want:

type KindA = { kind: 'a' };
type KindB = { kind: 'b' };
type KindC = { kind: 'c' };
type AllKinds = KindA | KindB | KindC;

function create<Kind extends AllKinds['kind']>(kind: Kind): Extract<AllKinds, { kind: Kind }>
function create(kind: AllKinds['kind']) {
    switch (kind) {
        case "a": return { kind: 'a' };
        case "b": return { kind: 'b' };
        case "c": return { kind: 'c' };
    }
}

const result1 = create("a") // KindA
const result2 = create("b") // KindB
const result3 = create("c") // KindC

Playground

As you might have noticed, I have used function overloading. It makes TS compiler less strict. In other words, provides a bit of unsafety but in the same time makes it more readable and infers return type.

AFAIK, function overloads behaves bivariantly. Hence, it is up to you which option is better

CodePudding user response:

The error is quite descriptive.

Type '{ kind: "a"; }' is not assignable to type 'T'.
  '{ kind: "a"; }' is assignable to the constraint of type 'T',
  but 'T' could be instantiated with a different subtype of constraint 'AllKinds'.

This problem occurs even when you use a simpler type constraint.

function foo<T extends string>(): T {
  return 'foo';
}

Here we'd get the following error.

Type 'string' is not assignable to type 'T'.
  'string' is assignable to the constraint of type 'T',
  but 'T' could be instantiated with a different subtype of constraint 'string'.

The problem is that we said that we'd return something of type T, but T is not the same type as string. Yes, the type T extends string but all that means is that T is a subtype of string. For example, the type 'bar' is a subtype of string. Hence, we can instantiate T with 'bar'. Hence, we'd expect the return value to be 'bar' but the return value is 'foo'.

The solution is to simply not use generics. If you want to return a string then just say that you're returning a string. Don't say that you're returning a value of some subtype T of string.

function foo(): string {
  return 'foo';
}

Similarly, if you want to return a value of type AllKinds then just say that you're returning a value of type AllKinds. Don't say that you're returning a value of some subtype T of AllKinds.

type KindA = {kind:'a'};
type KindB = {kind:'b'};
type KindC = {kind:'c'};
type AllKinds = KindA | KindB | KindC;

function create(kind:AllKinds['kind']): AllKinds {
  switch(kind) {
    case "a": return {kind:'a'};
    case "b": return {kind:'b'};
    case "c": return {kind:'c'};
  }
}

create("a");

Edit: You need dependent types to do what you want. TypeScript doesn't have dependent types. However, you can create a custom fold function that provides additional type safety.

type Kind = 'a' | 'b' | 'c';

type KindA = { kind: 'a' };
type KindB = { kind: 'b' };
type KindC = { kind: 'c' };

type AllKinds = KindA | KindB | KindC;

function foldKind<A, B, C>(a: A, b: B, c: C): (kind: Kind) => A | B | C {
  return function (kind: Kind): A | B | C {
    switch (kind) {
      case 'a': return a;
      case 'b': return b;
      case 'c': return c;
    }
  }
}

const create: (kind: Kind) => AllKinds = foldKind<KindA, KindB, KindC>(
  { kind: 'a' },
  { kind: 'b' },
  { kind: 'c' }
);

Now, you can only provide a value of KindA for 'a', a value of KindB for 'b', and a value of KindC for 'c'. See the demo for yourself.

  • Related