#Function type assignability with generic parameters

61 messages · Page 1 of 1 (latest)

potent basalt
#

Hi! I'm trying to understand an error I'm getting when assigning a function type that accepts a wider set of potential values to a type that accepts a narrower set. See playground link below for a reproduction.

The main thing that confuses me is the error Type 'PartialBook[Key]' is not assignable to type 'Book[Key]'. Shouldn't this be the other way around, with me assigning the function that takes Book[Key] to a function type that takes PartialBook[Key]?

I remember reading about this particular behaviour before, but I can't find the relevant discussion or docs. Any clarification on what's happening here would be appreciated!

limpid cradleBOT
#
dragory#0

Preview:```ts
interface Book {
id: number
name: string
pages: number
created_at: string
}

type FnThatUsesAllBookKeys = <Key extends keyof Book>(
key: Key,
value: Book[Key]
) => void

type PartialBook = Pick<Book, "name" | "pages">

type FnThatUsesSomeBookKeys = <
Key extends keyof PartialBook

(
key: Key,
value: PartialBook[Key]
) => void
...```

quiet flint
#

!:vari%

limpid cradleBOT
#
tjjfvi#0
`!t6:variance`:

Here's the example with Dog and Animal, explaining co/contra/in variance:

  • Covariance: () => Dog is assignable to () => Animal, because Dog is assignable to Animal; it "preserves the direction of the assignability"
  • Contravariance: (Animal) => void is assignable to (Dog) => void, because something that expects an Animal can also take a Dog; it "reverses the direction of the assignability"
  • Invariance: (Animal) => Animal is not assignable to (Dog) => Dog, because not all returned Animals are Dogs, and (Dog) => Dog is not assignable to (Animal) => Animal, because something expecting a Dog cannot take any other kind of Animal
quiet flint
#

this?

#

Book is a narrower type than Partial<Book>, because it requires the keys all exist

potent basalt
#

Hmm.. wouldn't the use case here be sound though? If you're expecting the function to take any of the keys of PartialBook and the corresponding value, shouldn't a function that takes any of the keys of Book (and a corresponding value) be assignable there as well?

potent basalt
potent basalt
#

And in fact, the opposite fails here too (which makes sense to me, as FnThatUsesSomeBookKeys can't handle the missing 2 keys):

limpid cradleBOT
#
dragory#0

Preview:ts ... const test: FnThatUsesAllBookKeys = (() => {}) as FnThatUsesSomeBookKeys ...

quiet flint
#

i hadn't checked the playground before, since i was on mobile. im on pc now, give me a sec

#

oh that's a weird case.

#

i thought PartialBook was Partial<Book> before

quiet flint
# limpid cradle

it makes sense that this fails, but idk why the original case is failing

#

let me reduce it a bit.

#

a lot of things appear to fix the error, and i have no clue why lmao

limpid cradleBOT
#
that_guy977#0

Preview:```ts
interface X {
a: string;
b: string; // making b and c the same type fixes
c: number;
}

type ConsumeX = <Key extends keyof X>(key: Key, value: X[Key]) => void; // removing this generic fixes

type HalfX = Pick<X, "b" | "c">; // removing either b or c fixes
...```

potent basalt
#

that is such a weird thing that removing either b or c fixes it there, lol

#

okay, in the original, changing the Pick to only use properties with the same type also works

quiet flint
#

well since removing generics fixes it, it's something weird with variance and generics, i guess

potent basalt
#

yeah, I imagine so

#

this works, for example:

limpid cradleBOT
#
dragory#0

Preview:```ts
...
function withExtendedFn<
Fn extends FnThatUsesSomeBookKeys

(fn: Fn) {}
withExtendedFn((() => {}) as FnThatUsesAllBookKeys)
...```

potent basalt
#

as does this:

limpid cradleBOT
#
dragory#0

Preview:ts ... function withExtendedFn<T extends PartialBook>( fn: FnWithKeys<T> ) {} withExtendedFn((() => {}) as FnWithKeys<Book>) withExtendedFn((() => {}) as FnWithKeys<PartialBook>) ...

potent basalt
#

so I might have to just pull the generic up a level here

#

I'll leave this thread open in case someone has insight into what's happening here exactly, but thank you for the help!

quiet flint
#

there are plenty of wizards with deeper knowledge than me, perhaps one of them can provide some insight lol

static dragon
#

@potent basalt The easiest way is to not use generic because it's not necessary:

limpid cradleBOT
#
nonspicyburrito#0

Preview:```ts
interface Book {
id: number
name: string
pages: number
created_at: string
}

type ArgsOf<T> = {
[K in keyof T]: [key: K, value: T[K]]
}[keyof T]

type BookArgs = ArgsOf<Book>

type FnThatUsesAllBookKeys = (
...args: BookArgs
) => void

type PartialBook = Pick<Book, "name" | "pages">
...```

static dragon
#

Although I suppose it depends on what you are doing in the function implementation.

quiet flint
#

using a generic makes it possible to misalign the 2 args from a union

static dragon
#

Ah yeah forgot about that.

#

Generic used in this way is not safe, you would need to wrap it in U2I.

potent basalt
#

the mapped type implementation is interesting though! I haven't thought about using that for parameters

static dragon
potent basalt
quiet flint
#

yes, though it's not using the mapped type directly, it's using the mapped type to generate a discriminated union

potent basalt
#

yeah

#

that.. somewhat feels like a hacky way to create generics? 😛

static dragon
potent basalt
#

how so?

static dragon
#
interface Book {
    id: number
    name: string
}

declare const fn: <Key extends keyof Book>(key: Key, value: Book[Key]) => void

fn<'id' | 'name'>('id', 'not a number')
#

This passes type check.

quiet flint
#
function twoOfSameType<T extends "a" | "b">(a: T, b: T) {}
twoOfSameType("a", "b"); // not of same type
// ^? - twoOfSameType<"a" | "b">
static dragon
#

The DU solution will actually prevent you from this.

potent basalt
#

...huh, I see

quiet flint
#

it's [A | B, A | B] vs [A, A] | [B, B]

#

a plain generic turns into the former

static dragon
#

Practically, this is rarely ever an issue so it's mostly fine. The bigger issue of the generic solution is that inside your function implementation, you won't be able to narrow key at all, unlike DU which you can.

#

But that depends on what you are actually doing inside your implementation, which may or may not be relevant.

potent basalt
#

I think that's something I've run into in the past with the generic implementation, so that's a very interesting point

static dragon
limpid cradleBOT
#
interface Book {
    id: number
    name: string
}

const fn = <Key extends keyof Book>(key: Key, value: Book[Key]) => {
    if (key === 'id') {
        value
//         ^? - (parameter) value: Book[Key]
    } else {
        key
//         ^? - (parameter) key: Key extends keyof Book
        value
//         ^? - (parameter) value: Book[Key]
    }
}
static dragon
#

With DU solution:

limpid cradleBOT
#
interface Book {
    id: number
    name: string
}

const fn = (...[key, value]: ['id', number] | ['name', string]) => {
    if (key === 'id') {
        value
//         ^? - (parameter) value: number
    } else {
        key
//         ^? - (parameter) key: "name"
        value
//         ^? - (parameter) value: string
    }
}
potent basalt
#

makes sense!

#

thank you so much! I hadn't thought about the lack of safety with the generic implementation, even if it's not a problem in most cases in practice

#

being able to narrow the key/value pair inside the implementation will also be useful, though not something I've done often