Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why don't Parameters<Func> extend unknown[] (Array<unknown>) in ts strict mode

The title pretty much says it all really. I have this code:

  type testNoArgsF = () => number;
  type testArgsF = (arg1: boolean, arg2: string) => number;
  type unknownArgsF = (...args: unknown[]) => number;
  type anyArgsF = (...args: any[]) => number;

  type testII = testArgsF extends anyArgsF ? true : false; // true
  type testIII = Parameters<testArgsF> extends Parameters<unknownArgsF>
    ? true
    : false; // true

  // unexpected:
  type testIV = testArgsF extends unknownArgsF ? true : false; // false <- why?
  // even though:
  type testV = testNoArgsF extends unknownArgsF ? true : false; // true

It is written in typescript (version 3.8), and I have strict mode enabled. The unexpected result is that a test function doesn't extend a function type with spread args of unknown[], yet if you just check the parameters they do extend unknown[]. Since the return type is always number, I don't understand what else could possibly be different to falsify the extends statement.

Other notes:

  • The extend statement is true only if your test function has 0 arguments.
  • This behaviour is not seen if you turn off strict mode.
like image 869
Geoff Davids Avatar asked Feb 03 '26 22:02

Geoff Davids


2 Answers

With the --strictFunctionTypes compiler option enabled, function type parameters are checked contravariantly. "Contra-variant" means that the subtype relationship of the function varies in the opposite direction from that of function parameters. So if A extends B, then (x: B)=>void extends (x: A)=>void and not vice-versa.

This is a type safety issue due to the nature of "substitutability" in TypeScript, also known as behavioral subtyping. If A extends B is true, you should be able to use an A as a B. If you can't, then A extends B is not true.

If you turn off --strict then the compiler uses the pre-TS-2.6 behavior of checking function parameters bivariantly, which is unsafe, but was allowed for reasons of productivity. That might be off-topic here, but you can read more about it in the TypeScript FAQ entry for "Why are function parameters bivariant?"


Anyway, if you need a function type that accepts any number unknown parameters, you cannot safely use a function that only a specific subtype of unknown. Observe:

const t: testArgsF = (b, s) => (b ? s.trim() : s).length
const u: unknownArgsF = t; // error!

u(1, 2, 3); // explosion at runtime! s.trim is not a function

If testArgsF extends unknownArgsF were true, then you would be able to assign t to u above without error, leading immediately to runtime errors when u happily accepts a non-string second argument.

You can see that the only safe way to subtype/implement a function type is for the subtype/implementation to accept arguments that are the same or wider than those expected by the supertype/call-signature. That's why --strictFunctionTypes was introduced to the language.


If you change unknown to any (using anyArgsF instead of unknownArgsF) then the compiler will not complain because any is intentionally unsound in TypeScript. The type any is considered to be assignable both to and from every other type; that's unsafe because, for example string extends any and any extends number are both true while string extends number is false. The substitution principle above is therefore not enforced when any is involved. Annotating a value as the any type is equivalent to loosening or turning off type checking for that value. That doesn't save you from the runtime error; it just silences the compiler's error:

const a: anyArgsF = t; // okay, type checking with any is disabled/loosened
a(1, 2, 3); // same explosion at runtime!

In the case where testNoArgsF extends unknownArgsF is true, this is also a consequence of substitutability. You can use a function that takes no arguments as if it were just about any function type, since it will (usually) end up ignoring any arguments passed into it:

const n: testNoArgsF = () => 1;
const u2: unknownArgsF = n; // okay
u2(1, 2, 3); // okay at runtime, since `n` ignores its arguments

This is explained in the TypeScript FAQ entry "Why are functions with fewer parameters assignable to functions that take more parameters?".


Okay, hope that helps; good luck!

Playground link to code

like image 158
jcalz Avatar answered Feb 05 '26 12:02

jcalz


I'll try to put it simple: "extends" -> "compatible" -> "can be used in place of"

Can you use (arg1: boolean, arg2: string) => number;
instead of (...args: unknown[]) => number?

No, because the latter can handle call without arguments, but the former might fail at runtime (e.g. will try to access properties of the arguments)

More info on functions compatibility here

like image 26
Aleksey L. Avatar answered Feb 05 '26 11:02

Aleksey L.