1

I’m struggling to reduce the type of a function argument inside that function. In my mind whenever I do an if-check, that shrinks possible values into smaller subset, the type checker reduces the type. What was a surprise to me is that a generic type doesn’t get reduced even when I explicitly check that the generic type variable is exactly one specific value.

Here is an example that demonstrates the problem (note the FIXMEs):

type NewsId = number

type DbRequestKind =
    | 'DbRequestGetNewsList'
    | 'DbRequestGetNewsItemById'

type DbRequest<K extends DbRequestKind>
    = K extends 'DbRequestGetNewsList'     ? { kind: K }
    : K extends 'DbRequestGetNewsItemById' ? { kind: K, newsId: NewsId }
    : never;

type DbResponse<K extends DbRequestKind>
    = K extends 'DbRequestGetNewsList'     ? number[]
    : K extends 'DbRequestGetNewsItemById' ? number
    : never

function dbQuery<K extends DbRequestKind>(req: DbRequest<K>): DbResponse<K> {
    if (req.kind === 'DbRequestGetNewsList') {
        const result = [10,20,30]
        return result as DbResponse<K> // FIXME doesn’t check valid K
    } else if (req.kind === 'DbRequestGetNewsItemById') {
        // FIXME “Property 'newsId' does not exist on type 'DbRequest<K>'.”
        // const result = req.newsId + 10
        const result = 10
        return result as DbResponse<K> // FIXME doesn’t check valid K
    } else {
        throw new Error('Unexpected kind!')
    }
}

{
    const x = dbQuery({ kind: 'DbRequestGetNewsList' })

    // Check that response type is inferred
    const y: typeof x = [10]
    // const z: typeof x = 10 // fails (as intended, it’s good)

    console.log('DB response (list):', x);
}

{
    const x = dbQuery({ kind: 'DbRequestGetNewsItemById', newsId: 5 })

    // Check that response type is inferred
    // const y: typeof x = [10] // fails (as intended, it’s good)
    const z: typeof x = 10

    console.log('DB response (item by id):', x);
}

It’s just a copy taken from https://github.com/unclechu/typescript-dependent-types-experiment/blob/master/index.ts. So as you can see this is an example of dependent typing. I want return type DbResponse<K> to depend on the function argument DbRequest<K>.

Let’s look at FIXMEs:

  1. Example:

    if (req.kind === 'DbRequestGetNewsList') {
        return [10,20,30]
    }
    

    Fails with: Type 'number[]' is not assignable to type 'DbResponse<K>'.

    Or:

    if (req.kind === 'DbRequestGetNewsItemById') {
        return 10
    }
    

    Fails with: Type 'number' is not assignable to type 'DbResponse<K>'.

    But I explicitly check for the kind, and you can see the condition: K extends 'DbRequestGetNewsList' ? number[] as well as K extends 'DbRequestGetNewsItemById' ? number.

    In the example you can see that I’m casting those returned values to generic type (as DbResponse<K>) but this kills the types. For instance I can do this:

    if (req.kind === 'DbRequestGetNewsList') {
        return 10 as DbResponse<K>
    } else if (req.kind === 'DbRequestGetNewsItemById') {
        return [10,20,30] as DbResponse<K>
    }
    

    Which is totally wrong and the type checker just swallows it with no sound.

  2. Next one that you can see is Property 'newsId' does not exist on type 'DbRequest<K>'..

    Actually this can be fixed by using sum-type for DbRequest<K> instead of type conditions. But this would create another problem where a call of dbQuery would return generic type again instead of inferring it, thus:

    const x = dbQuery({ kind: 'DbRequestGetNewsList' })
    const y: typeof x = [10]
    const z: typeof x = 10 // FIXME This must fail but it doesn’t with sum-type!
    

I believe these two problems are connected to the same source, to the fact that K inside the body of dbQuery function can’t be inferred even after explicit if-condition check for single particular K. This is really counterintuitive. Does it work for any case but not for generics? Can I overcome this somehow and make the type checker to do its job?

UPD #1

It’s even impossible to write a type prover:

function proveDbRequestGetNewsListKind<K extends DbRequestKind>(
    req: DbRequest<K>
): req is DbRequest<'DbRequestGetNewsList'> {
    return req.kind === 'DbRequestGetNewsList'
}

It fails with:

A type predicate's type must be assignable to its parameter's type.
  Type '{ kind: "DbRequestGetNewsList"; }' is not assignable to type 'DbRequest<K>'.

UPD #2

Initially my solution was built on top of overloads. It doesn’t solve the problem. See https://stackoverflow.com/a/66119805/774228

Consider this:

function dbQuery(req: DbRequest): number[] | number {
    if (req.kind === 'DbRequestGetNewsList') {
        return 10
    } else if (req.kind === 'DbRequestGetNewsItemById') {
        return [10,20,30]
    } else {
        throw new Error('Unexpected kind!')
    }
}

This code is broken. Type checker is okay with it though.

The problem with overloads is that you can’t provide separate implementation for each overload. Instead you provide generic implementation which includes bigger subset of types. Thus you loose type safety, it gets more runtime error-prone.

Apart from this you have to manually provide more and more overloads, for each type (just like in Go, meh).

UPD #3

I’ve improved type checking a bit by adding a closure with type casting. It’s far from perfect but better.

function dbNewsList(
    req: DbRequest<'DbRequestGetNewsList'>
): DbResponse<'DbRequestGetNewsList'> {
    return [10, 20, 30]
}

function dbNewsItem(
    req: DbRequest<'DbRequestGetNewsItemById'>
): DbResponse<'DbRequestGetNewsItemById'> {
    return req.newsId + 10
}

function dbQuery<K extends DbRequestKind>(req: DbRequest<K>): DbResponse<K> {
    return (req => {
        if (req.kind === 'DbRequestGetNewsList') {
            return dbNewsList(req)
        } else if (req.kind === 'DbRequestGetNewsItemById') {
            return dbNewsItem(req)
        } else {
            throw new Error('Unexpected kind!')
        }
    })(
        req as DbRequest<'DbRequestGetNewsList' | 'DbRequestGetNewsItemById'>
    ) as DbResponse<K>;
}

UPD #4

I slightly improved the latest example using T[K] hack which was proposed by @jcalz below (see https://stackoverflow.com/a/66127276). No need for additional functions for each kind.

type NewsId = number

type DbRequestKind = keyof DbResponseMap

type DbRequest<K extends DbRequestKind>
    = K extends 'DbRequestGetNewsList'     ? { kind: K }
    : K extends 'DbRequestGetNewsItemById' ? { kind: K, newsId: NewsId }
    : never

interface DbResponseMap {
    DbRequestGetNewsList: number[]
    DbRequestGetNewsItemById: number
}

type DbResponse<K extends DbRequestKind> = DbResponseMap[K]

function dbQuery<K extends DbRequestKind>(req: DbRequest<K>): DbResponse<K> {
    return (req => {
        if (req.kind === 'DbRequestGetNewsList') {
            const result: DbResponseMap[typeof req.kind] = [10, 20, 30]
            return result
        } else if (req.kind === 'DbRequestGetNewsItemById') {
            const result: DbResponseMap[typeof req.kind] = req.newsId + 10
            return result
        } else {
            const _: never = req
            throw new Error('Unexpected kind!')
        }
    })(req as DbRequest<DbRequestKind>) as DbResponse<K>
}

UPD #5

One more improvement. I added additional constraint for returned type of the closure. Also I reduced amount of extra entities in the pattern.

type NewsId = number

type DbRequest<K extends keyof DbResponseMap>
    = K extends 'DbRequestGetNewsList'     ? { kind: K }
    : K extends 'DbRequestGetNewsItemById' ? { kind: K, newsId: NewsId }
    : never

interface DbResponseMap {
    DbRequestGetNewsList: number[]
    DbRequestGetNewsItemById: number
}

function dbQuery<K extends keyof DbResponseMap>(req: DbRequest<K>): DbResponseMap[K] {
    return ((req): DbResponseMap[keyof DbResponseMap] => {
        if (req.kind === 'DbRequestGetNewsList') {
            const result: DbResponseMap[typeof req.kind] = [10, 20, 30]
            return result
        } else if (req.kind === 'DbRequestGetNewsItemById') {
            const result: DbResponseMap[typeof req.kind] = req.newsId + 10
            return result
        } else {
            const _: never = req
            throw new Error('Unexpected kind!')
        }
    })(req as DbRequest<keyof DbResponseMap>) as DbResponseMap[K]
}
2
  • 3
    TypeScript doesn't have dependent types; I think the most I could do is point you to relevant GitHub issues like microsoft/TypeScript#13995 and suggest that type assertions (what you're calling "casting") is probably the closest you'll get to the behavior you want, because the compiler is unable to verify type safety for you. I can look in more detail but I'm a bit reluctant to wade into the fray when people seem to be downvote-happy here Commented Feb 9, 2021 at 17:32
  • @jcalz Thank you for your answer, it’s nice to at least get an answer that this is technically impossible at the moment. Though I don’t understand the worries about downvotes. If you don’t like how they work blame SO. I mean an upvote means “useful” and downvote means “not useful”, am I right? That’s how people get to know which answer may solve the problem and which just can’t, right? Commented Feb 9, 2021 at 18:52

2 Answers 2

3

As mentioned in the comments, TypeScript doesn't really have support for dependent types, especially not when it comes to type checking the implementation of a function whose call signature implies such a dependency. The general problem you're facing is mentioned in a number of GitHub issues, notably microsoft/TypeScript#33014 and microsoft/TypeScript#27808. Currently the two main ways to go here are: write an overloaded function and be careful with the implementation, or to use a generic function with type assertions and be careful with the implementation.


Overloads:

For overloads, the implementation is intentionally checked more loosely than the set of call signatures. Essentially as long as you return a value that at least one of the call signatures expects, there will not be an error for that return value. This is, as you saw, unsafe. It turns out that TypeScript is not fully type safe or sound; in fact this is explicitly not a TypeScript language design goal. See non-goal #3:

  1. Apply a sound or "provably correct" type system. Instead, strike a balance between correctness and productivity.

Inside the implementation of an overloaded function, the TS team favors productivity over correctness. It is essentially the implementer's job to guarantee type safety; the compiler does not really attempt to do it.

See microsoft/TypeScript#13235 for more information. It was suggested to catch such errors, but the suggestion was closed as "Too Complex". Doing overloads "the right way" would require much more work for the compiler and there isn't enough evidence that these sorts of mistakes are made often enough to make the added complexity and performance penalty worthwhile.


Generic functions:

The problem here is sort of the opposite; the compiler is unable to see that the implementation is safe, and will give you an error for just about anything you return. Control flow analysis does not narrow an unresolved generic type parameter or a value of an unresolved generic type. You can check req.kind, but this is not used by the compiler to do anything to the type of K. Arguably you cannot narrow K by checking a value of type K because it might still be the full union.

See microsoft/TypeScript#24085 for more discussion about this issue. Doing this "the right way" would require some fundamental changes to the way generics are handled. It's an open issue at least, so there's some hope that something might be done in the future, but I wouldn't rely on it.

If you want the compiler to accept something it cannot verify, you should double-check that you're doing it right and then use a type assertion to silence the compiler warning.


For your specific example we can do a little better. One of the few places TypeScript does an attempt to model dependent types is when looking up an object property type from a literal key type. If you have a value t of type T, and a key k of type K extends keyof T, then the compiler will understand that t[k] is of type T[K].

Here's how we can rewrite what you're doing to take the form of such an object property lookup:

interface DbRequestMap {
  DbRequestGetNewsList: {};
  DbRequestGetNewsItemById: { newsId: NewsId }
}
type DbRequestKind = keyof DbRequestMap;
type DbRequest<K extends DbRequestKind> = DbRequestMap[K] & { kind: K };

interface DbResponseMap {
  DbRequestGetNewsList: number[];
  DbRequestGetNewsItemById: number;
}
type DbResponse<K extends DbRequestKind> = DbResponseMap[K]

function dbQuery<K extends DbRequestKind>(req: DbRequest<K>): DbResponse<K> {
  return {
    get DbRequestGetNewsList() {
      return [10, 20, 30];
    },
    get DbRequestGetNewsItemById() {
      return 10; 
    }
  }[req.kind];
}

Here we represent DbRequest<K> as a value with a {kind: K} property and DbResponse<K> as a value of type DbResponseMap[K]. In the implementation we make an object of type DbResponseMap with getters to prevent the entire object from being calculated, and then look up its req.kind property of type K... to get a DbResponse<K> the compiler is happy with.

It's not perfect by a long shot though. Inside the implementation the compiler still cannot narrow req to anything that has, say, a newsId property. So you'll find yourself still narrowing unsafely:

return (req as DbRequest<DbRequestKind> as 
  DbRequest<"DbRequestGetNewsItemById">).newsId + 10; // 🤮

So I think in practice you should just pick your poison and deal with type safety being violated somewhere inside your implementation. If you're careful you can at least maintain type safety for the callers of your function, which is the best we can hope for with TypeScript 4.1 at any rate.


Playground link to code

Sign up to request clarification or add additional context in comments.

3 Comments

The hack with T[K] is smart. But as long as I can do this: get DbRequestGetNewsList() { return [(req as DbRequest<DbRequestKind> as DbRequest<'DbRequestGetNewsItemById'>).newsId + 10] } which is NaN in runtime I’m left unsatisfied. Thanks for the very good and detailed answer though!
See UPD #4 section in the topic. I slightly improved my latest example using your T[K] hack. This is the safest pattern one could get I think.
I even added one more improvement in UPD #5 section, closure return type constraint. Also I reduced amount of extra entities.
0

Here you have working code:

type NewsId = number

type DbRequestKind =
  | 'DbRequestGetNewsList'
  | 'DbRequestGetNewsItemById'

type DbRequest<K extends DbRequestKind>
  = K extends 'DbRequestGetNewsList' ? { kind: K }
  : K extends 'DbRequestGetNewsItemById' ? { kind: K, newsId: NewsId }
  : never;

type DbResponse<K extends DbRequestKind>
  = K extends 'DbRequestGetNewsList' ? number[]
  : K extends 'DbRequestGetNewsItemById' ? number
  : never

type Distributive<T> = [T] extends [any] ? T : never


function dbQuery<K extends DbRequestKind>(req: DbRequest<'DbRequestGetNewsItemById'>): DbResponse<'DbRequestGetNewsItemById'>
function dbQuery<K extends DbRequestKind>(req: DbRequest<'DbRequestGetNewsList'>): DbResponse<'DbRequestGetNewsList'>
function dbQuery(req: DbRequest<DbRequestKind>): Distributive<DbResponse<DbRequestKind>> {
  if (req.kind === 'DbRequestGetNewsList') {
    const result = [10, 20, 30]
    return result // FIXME doesn’t check valid K
  } else if (req.kind === 'DbRequestGetNewsItemById') {
    const result = req.newsId + 10 // error
    //return '2' // error
    return 2 // error
  } else {
    const x = req // never
    throw new Error('Unexpected kind!')
  }
}

Please keep in mind, that K extends DbRequestKind is not the same as DbRequestKind, because K can be much wider. This made the trick

1 Comment

I just gave you an example to show where is the problem, but the same problem was demonstrated in the topic, also with overloads which I referred to in the comment above (see UPD #2 section). My example compiles, and that is the problem. Because I return number[] for “news item” response, and number for “news list” which is absolutely wrong. I demonstrated exactly that in my first initial example, and additionally in UPD #2 section. I downvoted because it doesn’t help to solve the issue. Not for me, not for anyone else, downvotes are exactly for this purpose.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.