2

I have an object of data I need to pass to one of two places based on which union member the object currently contains. Both places need all of the data in the object so recreating the object with the narrowed type seems a little silly. Obviously it works though.

As an alternative, I tried making the object's interface generic over the union so the interface can represent the object in all three places, thinking the automatic type narrowing might apply to the type parameter of TestData.

interface UserData {
    kind: 'user',
    user: string,
}

interface ServerData {
    kind: 'server',
    url: string,
}

type DataTypes = UserData | ServerData

interface TestData<D extends DataTypes> {
    data: D,
    id: string,
}

Now the top class can use TestData<DataTypes> and the children can use TestData<UserData> or TestData<ServerData>. This works fine until you try and pass the object down to one of the children. The compiler will correctly narrow TestData's data property, but this doesn't narrow the type of the actual object, which still has a type of TestData<DataTypes>. Here's an example.

function basicNarrow(test: TestData<DataTypes>) {
    if (test.data.kind === 'user') {
        // Correctly narrowed to `UserData`
        test.data.user 
        // Error: Generic type not narrowed, still `TestData<DataTypes>
        const typed: TestData<UserData> = test 
    } else {
        // Correctly narrowed to `ServerData`
        test.data.url 
        // Error: Generic type not narrowed, still `TestData<DataTypes>
        const typed: TestData<ServerData> = test 
    }
}

At this point I could either use a type assertion or (again) create a new object to pass down the correct type, but after some digging I found this answer to a similar question which gives me

type NarrowKind<T, N> = T extends { kind: N } ? T : never;

function predicateNarrow(test: TestData<DataTypes>) {
    const predicate = <K extends DataTypes['kind']>(narrow: TestData<DataTypes>, kind: K): narrow is TestData<NarrowKind<DataTypes, K>> => (
        narrow.data.kind === kind
    )

    if (predicate(test, 'user')) {
        // Correctly narrowed to `UserData`
        test.data.user 
        // Success! Generic type narrowed to `TestData<UserData>
        const typed: TestData<UserData> = test 
    } else {
        // Error: Not narrowed
        test.data.url 
        // Error: Generic type not narrowed, still `TestData<DataTypes>
        const typed: TestData<ServerData> = test 
    }
}

This does what I was after inside the if block, but the compiler won't narrow to the alternate case in the else block without another explicit check the way it would if data was just a local variable.

Here's an example of what I'd like the narrowed types to end up being ideally

function idealNarrow(test: TestData<DataTypes>) {
    function isKind(/*???*/) { /*???*/ }

    if (isKind(test, 'user')) {
        const user: UserData = test.data 
        const typed: TestData<UserData> = test 
    } else {
        const server: ServerData = test.data 
        const typed: TestData<ServerData> = test 
    }
}

Either of the solutions could be used without issue, but predicateNarrow(...) is so close to what I was looking for, is there a way to combine these two behaviors somehow to narrow the whole generic TestData<D> type automatically in the else block?

Cole
  • 103
  • 6

1 Answers1

1

The issue here is that TestData itself is no discriminated union type, only D of contained data property is. In other words, TS can narrow data by kind discriminant, not the outer TestData type.

predicate can only check TestData to contain a specific type UserData or ServerData, but it cannot infer the other possible unions parts with the control flow in an if/else block. Possible solutions:

1) Narrow DataTypes and recombine TestData (code)

function basicNarrow({ id, data }: TestData<DataTypes>) {
    if (data.kind === 'user') {
        data // UserData
        const typed: TestData<UserData> = { id, data }
    } else {
        data // ServerData
        const typed: TestData<ServerData> = { id, data }
    }
}

2) Make TestData itself a discriminated union (code)

type DataTypes = UserData | ServerData
type TestData<D extends DataTypes> = D & { id: string }

function basicNarrow(test: TestData<DataTypes>) {
    if (test.kind === 'user') {
        test // UserData & { id: string; }
        const typed: TestData<UserData> = test
    } else {
        test // ServerData & { id: string; }
        const typed: TestData<ServerData> = test
    }
}
ford04
  • 66,267
  • 20
  • 199
  • 171