3

In the codebase I'm working on, it makes sense to have a generalised type that includes a function with a parameter of type unknown. Then wherever that type is used, narrow the function's parameter type to something more specific. However, this raises an error like:

Type 'unknown' is not assignable to type Foo

Following is some simple code to illustrate what I'm trying to do:

interface Foo {
  func: (arg0: unknown) => number;
}

type SolidType = {
  someNumber: number;
}

interface Bar extends Foo {
  func: (arg0: SolidType) => number;
}

const x: Bar = {
  func: (arg0) => {
    return arg0.someNumber;
  }
}

The above code raises the following error:

Interface 'Bar' incorrectly extends interface 'Foo'.
  Types of property 'func' are incompatible.
    Type '(arg0: SolidType) => number' is not assignable to type '(arg0: unknown) => number'.
      Types of parameters 'arg0' and 'arg0' are incompatible.
        Type 'unknown' is not assignable to type 'SolidType'.ts(2430)

Why is it not possible to override an unknown with a specific type in this case? Am I using unknown incorrectly here, and if so is there a better alternative?

10
  • 1
    It's not possible since it's not safe; if Bar extends Foo then I should be able to use a Bar anywhere a Foo is required. Imagine function takeFoo(foo: Foo) { foo.func("this is fine"); }. Is x a Foo or not? If so, then I should be able to call takeFoo(x), but that would do bad things at runtime. That's why class Bar extends Foo is an error. If you don't care about safety then use any instead of unknown like this. If you care about safety then use never like this. Can you elaborate on the use case? Commented Sep 16, 2021 at 1:56
  • 1
    I mean, could you show some example uses of Foo where you don't know it's of type Bar? What can you do with the base class? Commented Sep 16, 2021 at 1:57
  • 1
    A TS Playground link is almost always helpful! Commented Sep 20, 2021 at 22:40
  • 1
    @jcalz Feel free to post an answer to the OP using never and I'll accept it Commented Sep 22, 2021 at 3:18
  • 1
    Okay, I'm answering based on the Foo and Bar example, and you can translate that to your own code as I showed in your more recent playground link. Good luck! Commented Sep 22, 2021 at 16:00

2 Answers 2

4

With the --strictFunctionTypes flag enabled, the compiler protects you against unsafe function types by checking their parameter types contravariantly, which means that a function type (a: X) => void extends (or "is assignable to" or "is a subtype of") a function type (a: Y) => void if and only if Y extends X. Note that the assignability direction changes for the function type compared to that of its parameter type. That is, function types vary counter to their parameter types. In other words, they contra-vary.

Why does type safety require this? It has to do with direction of data flow. When data flows from a source to a target, the type the source sends must extend the type the target accepts. The source can safely get narrower (e.g., "I claimed to give you a Fruit, and I'm actually giving you a Banana") and the target can safely get wider (e.g., "you gave me a Fruit, and I'd actually accept any Food whatsoever"). It is unsafe for the source to get wider (e.g., "I claimed to give you a Fruit, but I'm actually giving you some Food which may or may not be a Fruit, I don't know, sorry) or for the target to get narrower (e.g., "you gave me a Fruit, but I really only accept a Banana and maybe you gave me something else, so I'm not satisfied")

When you pass data into a function parameter, the function is receiving the data, and thus the parameter type can be safely widened, not narrowed.


Let's examine your case:

interface Foo {
  func: (arg0: unknown) => number;
}

Here, Foo's func() method apparently, right off the bat, claims to accept an argument of type unknown. That means it is already as wide as it can possibly be. Callers of func() can pass in anything whatsoever that they like:

function takeFoo(foo: Foo) { foo.func("this is fine"); }

But your definition of Bar does this:

interface Bar {
  func: (arg0: SolidType) => number;
}

Any value of type Bar is free to expect that its func() method will be called with a SolidType:

const x: Bar = {
  func: (arg0) => {
    return Number(arg0.someNumber.toFixed(2));
  }
}

If Bar extends Foo were true, it would require that the following line should be just fine:

takeFoo(x) // <-- this should be allowed if Bar extends Foo

But of course at runtime, this would fail with arg0.someNumber is undefined.

By saying Bar extends Foo, you are unsafely narrowing the function parameter of func() from unknown ("I'll take anything!") to SolidType ("I lied when I said I'd take anything, sorry!").


So, how to fix it? Well, it really depends strongly on your use cases.

If you have a Foo that isn't known to be of some specific intended subtype like Bar, will you ever call its func() method? Do you really plan on supporting foo.func("this is fine") and foo.func(123) and foo.func(new Date())? I'm guessing not, and that you will only actually call func() if you have some specific subtype. In this case, it means that you might want to say that func's parameter should be the narrowest possible type, which is the never type:

interface Foo {
  func: (arg0: never) => number;
}

Now your subtype works with no error:

interface Bar extends Foo {
  func: (arg0: SolidType) => number; // okay, no error
}

And the takeFoo() from above is no longer valid, so you don't have to worry about someone mistaking a Bar for something that accepts any possible arg0:

function takeFoo(foo: Foo) { foo.func("this is fine"); } // error
// ---------------------------------> ~~~~~~~~~~~~~~

If your use case requires some particular behavior with func() on the base type Foo, then you might have to tweak never to something else. In order to be type safe though, subtype function parameters can get wider but not narrower. If you find that you really need the opposite, then you might choose to give up type safety by using something like the any type instead of never. That could lead to takeFoo()-style runtime errors, so you'd need to be careful, which is always true when you intentionally loosen type safety.

Playground link to code

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

1 Comment

That link is gold. It really helped me understand covariance and contravariance.
1

you can use Generic types here instead of unknown. For example:

interface Foo<T> {
  func: (arg0: T) => number;
}

type SolidType = {
  someNumber: number;
}

interface Bar extends Foo<SolidType> {
  func: (arg0: SolidType) => number;
}

const x: Bar = {
  func: (arg0) => {
    return arg0.someNumber;
  }
}

1 Comment

Sadly, that won't work, there's a lot of context here that would be tedious to explain. I am using generics for other things, but in this case they're not a good fit

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.