2

I've got a function that accepts a an enum value as T and a generic type Data<T> that chooses between two data types.

I hoped to be able to access properties of type BarData inside a conditional that should make T known. However, it still reads data as a union type.

The code works as expected but what do I have to change to get rid of the typescript errors?

enum DataType { Foo, Bar }
interface FooData { someKey: string }
interface BarData extends FooData { otherKey: string }

type Data<T extends DataType> = T extends DataType.Foo ? FooData : BarData

function func<T extends DataType>(type: T, data: Data<T>): void {
    const getter = <K extends keyof Data<T>>(key: K): Data<T>[K] => data[key]

    if (type === DataType.Bar) {
        data; // still inferred as Data<T>
        console.log(data.otherKey) // error Property 'otherKey' does not exist on type 'FooData | BarData'.
        console.log(getter('otherKey')) // error Argument of type 'string' is not assignable to parameter of type 'keyof Data<T>'.
    }
}

Playground link

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
DustInComp
  • 1,742
  • 8
  • 18
  • 2
    Consider [typeguards](https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards) – captain-yossarian from Ukraine Dec 01 '21 at 10:33
  • I don't think generics will ever resolve inside the function implementation – Guerric P Dec 01 '21 at 10:34
  • What is it you actually want `func` to do? With more specifics, we can be more specifically helpful. :-) But as @captain-yossarian said, you're probably going to need/want typeguards. – T.J. Crowder Dec 01 '21 at 10:39
  • 1
    I think this is basically the same question, with a helpful answer by @jcalz -- https://stackoverflow.com/questions/68142672/conditional-type-that-narrows-a-union In other words, the issue is that TS won't evaluate generics until instantiation, even though it appears knowable in this case because of your `if` statement. Since you "know" the correct type inside your if statement, an assertion is OK--or a typeguard if you want a little more guaranteed safety. – sam256 Dec 01 '21 at 10:41

1 Answers1

1

You need to make sure that invalid state is unrepresentable. You can use rest parameters instead of generic.

enum DataType { Foo = 'Foo', Bar = 'Bar' }

interface FooData { someKey: string }

interface BarData extends FooData { otherKey: string }


type MapStructure = {
  [DataType.Foo]: FooData,
  [DataType.Bar]: BarData
}

type Values<T> = T[keyof T]

type Tuple = {
  [Prop in keyof MapStructure]: [type: Prop, data: MapStructure[Prop]]
}

// ---- > BE AWARE THAT IT WORKS ONLY IN T.S. 4.6 < -----

function func(...params: Values<Tuple>): void {
  const [type, data] = params
  const getter = <Data, Key extends keyof Data>(val: Data, key: Key) => val[key]

  if (type === DataType.Bar) {
    const foo = type
    data; // BarData
    console.log(data.otherKey) // ok
    console.log(getter(data, 'otherKey')) // ok
    console.log(getter(data, 'someKey')) // ok

  }
}

Playground

MapStructure - is used just for mapping keys with valid state.

Values<Tuple> - creates a union of allowed tuples.Since rest parameters is nothing more than a tuple, it works like a charm.

Regarding getter. You should either define it inside if condition or make it separate function. SO, feel free to move getter out of the scope of func.

If you want to stick with generics, like in your original example, you should make type and data a part of one datastracture and then use typeguard

enum DataType { Foo, Bar }
interface FooData { someKey: string }
interface BarData extends FooData { otherKey: string }

type Data<T extends DataType> = T extends DataType.Foo ? FooData : BarData

const isBar = (obj: { type: DataType, data: Data<DataType> }): obj is { type: DataType.Bar, data: BarData } => {
    const { type, data } = obj;
    return type === DataType.Bar && 'other' in data
}

function func<T extends DataType>(obj: { type: T, data: Data<T> }): void {
    const getter = <K extends keyof Data<T>>(key: K): Data<T>[K] => obj.data[key]

    if (isBar(obj)) {
        obj.data // Data<T> & BarData
        console.log(obj.data.otherKey) // ok       
    }
}

But issue with getter still exists since it depend on uninfered obj.data. You either need to move out getter of func scope and provide extra argument for data or move getter inside conditional statement (not recommended).

However, you can switch to TypeScript nightly in TS playground and use object type for argument:

enum DataType { Foo, Bar }
interface FooData { someKey: string }
interface BarData extends FooData { otherKey: string }

type Data = { type: DataType.Foo, data: FooData } | { type: DataType.Bar, data: BarData }


function func(obj: Data): void {
    const { type, data } = obj;
    const getter = <K extends keyof typeof data>(key: K): typeof data[K] => data[key]

    if (type === DataType.Bar) {
        data // BarData
        console.log(obj.data.otherKey) // ok       
    }
}

Playground

getter still does not work in a way you expect, hence I recomment to move it out from func

  • 1
    Thanks. Your first solution seems to work only in the 4.6 nightly build as well. I'll be going with the generics + type predicates option. I'm glad this type check also works when passing arguments individually and building the object inside the function: https://tsplay.dev/wEGYvm – DustInComp Dec 01 '21 at 13:41
  • @DustInCompetent you are right, forgot to mention it – captain-yossarian from Ukraine Dec 01 '21 at 13:43