3

I have the following problem with the relationship between callable types in TypeScript:

type CheckExtends<T, U> = T extends U ? T : never

type NumberFunc = (op: number) => number
type AnyFunc = (op: any) => any;
type UnknownFunc = (op: unknown) => any;

type NumberAny = CheckExtends<NumberFunc, AnyFunc> // NumberFunc
type NumberUnknown = CheckExtends<NumberFunc, UnknownFunc> // never

(On the playground)

It's shown via comments that NumberFunc type is extending AnyFunc type and not UnknownFunc type.

I don't understand that because the parameter type of NumberFunc type (number) is assignable to both any and unknown types

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
sage agat
  • 103
  • 1
  • 7

2 Answers2

1

As soon as you introduce any, you lose a lot of the type safety guarantees in TypeScript. The any type is intentionally unsound to allow you to do things that the compiler thinks are unsafe. Everything is both assignable to and assignable from any; it's an escape hatch from the type system. Therefore it's not surprising that NumberFunc is seen as assignable to AnyFunc. You'd also have seen that AnyFunc is assignable to NumberFunc:

type AnyNumber = CheckExtends<AnyFunc, NumberFunc> // AnyFunc

It's best to ignore any then, and move on to the other part of your question using the type-safe unknown instead: why isn't NumberFunc assignable to UnknownFunc?


In order to preserve type safety, functions should be contravariant in their parameter types. This means that the subtype/supertype relationship between function types is opposite that of their parameters. So, for example, while number is a subtype of unknown, a function of type (x: number) => void is a supertype of (x: unknown) => void. So the assignability relationship has flipped. This might be confusing at first but it actually is the only thing that makes sense from a type safety perspective. This is a consequence of what's known as the Liskov Substitution Principle; if A is assignable to B, it means that you can replace any value of type B with one of type A and it won't cause any errors or problems.

Let's say I have a value n of type number. If you ask for a value of type unknown, you'll be happy if I give you n. You'd have been happy if I handed you "hello", or false, or any value. Because number is a subtype of unknown, you can use a number anywhere you need an unknown.

But now imagine that I have a value f of type (x: number) => void. If you ask for a value of type (x: unknown) => void, and I hand you f, you might be very unhappy when you try to use it. You think you have a function which accepts any possible parameter, so you expect to be able to call it like f("hello"), or f(false), or f(15). But f() only accepts number parameters, so f("hello") and f(false) are very likely to explode at runtime. The function you're asking for is very specific. Because you can't use an (x: number) => void in some places you can use an (x: unknown) => void, then the former is not a subtype of the latter.

In fact it's the reverse: if I have a value g of type (x: unknown) => void, and you ask for a value of type (x: number) => void, I can hand you g and you will be happy. You will only be calling g() with numeric parameters like g(1), g(2), or g(15), so you'll never have a problem at runtime. That means that UnknownFunc is a subtype of NumberFunc:

type UnknownNumber = CheckExtends<UnknownFunc, NumberFunc> // UnknownFunc

Okay, hope that helps; good luck!

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
0

As you can see in this article, typescript functions are only assignable if the target function's parameters are assignable to the source function's parameters when strictFunctionChecks is enabled. For example, if you have the following code:

interface A {
    a: number;
}

interface B extends A {
    b: number;
}
type AFunc = (op: A) => any;
type BFunc = (op: B) => any;
let bf: BFunc = x => x;
let af: AFunc = bf;

This will error because you cannot assign BFunc to Afunc due to the fact that A, the target parameter type, is not assignable to B, the source parameter type. Note that checking if AFunc extends BFunc is equivalent to checking if AFunc is assignable to BFunc. You cannot assign NumberFunc to UnknownFunc because unknown, the target parameter type, is not assignable to number, the source parameter type. This means that NumberFunc does not extend UnknownFunc. If you disable strictFunctionChecks in the playground you can see that NumberUnknown is no longer never and it becomes NumberFunc.

Aplet123
  • 33,825
  • 1
  • 29
  • 55