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:
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 asK 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.
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 ofdbQuerywould 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]
}