7

The TypeScript compiler tsc compiles the following code without complaints, even with the --strict flag. However, the code contains a basic bug, which is prevented in languages like Java or C#.

interface IBox<T> {
  value: T;
}

const numberBox: IBox<number> = { value: 1 };

function insertString(items: IBox<string | number>): void {
  items.value = 'Test';
}

// this call is problematic
insertString(numberBox);

// throws at runtime:
// "TypeError: numberBox.value.toExponential is not a function"
numberBox.value.toExponential();

Can tsc be configured so that such a bug is recognized?

ominug
  • 1,422
  • 2
  • 12
  • 28

3 Answers3

9

TypeScript doesn't really have a good general way to handle contravariance or invariance. To a rough approximation, if you are outputting something (function outputs, read-only properties) you can output something narrower but not wider than the expected type (covariance), and if you're inputting something (function inputs, write-only properties) you're allowed to accept something wider but not narrower than the expected type (contravariance). If you're reading and writing the same value, then you're not allowed to narrow or widen the type (invariance).

This issue doesn't show up as much in Java (not sure about C#) mostly because you can't easily create unions or intersections of types, and because in generics there are extends and super constraints that act as markers for covariance and contravariance. You do see this in Java arrays, at least, which are unsoundly considered covariant (try the above with Object[] and Integer[] and you will see fun happen).


TypeScript has generally done a good job with treating function outputs as covariant. Until TypeScript v2.6, the compiler treated function inputs as bivariant, which is unsound (but has some useful effects; read the linked FAQ entry). Now there is a --strictFunctionTypes compiler flag that lets you enforce contravariance for function inputs for standalone functions (not methods).

Currently, TypeScript treats property values and generic types as covariant, meaning they are fine for reading but not fine for writing. That leads directly to the issue you're seeing. Note that this is also true for property values so you can reproduce this problem without generics:

let numberBox: { value: number } = { value: 1 };
function insertString(items: { value: string | number }): void {
  items.value = 'Test';
}
insertString(numberBox);
numberBox.value.toExponential();

I don't have great advice other than "be careful". TypeScript is not intended to have a strictly sound type system (see non-goal #3); instead, the language maintainers tend to address issues only to the extent that they cause real-world bugs in programs. If this kind of thing affects you a lot, maybe head over to Microsoft/TypeScript#10717 or a similar issue and give it a or describe your use case if you think it's persuasive.

Hope that's helpful. Good luck!

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thank you very much! “TypeScript treats property values and generic types as covariant”—I did not notice until now that reading from generics is type safe. However, your statement seems not completely correct. A generic seems to be only regarded covariant if it contains at least one covariance relevant member (e.g. `getValue(): T`). Assigment `let x: ISetter = > y` of a generic with at least one contravariance relevant member (e.g. `setValue(a: T)`) is allowed iff `T ext. U` or `U ext. T`. A generic without members depending on `T` is unrestricted related to its type parameter. – ominug Dec 17 '17 at 13:23
  • I also discovered that the following does not compile. `let numberBox: { set: (x: number) => void } = { set: () => null }; function insertString(items: { set: (x: string | number) => void }): void { items.set('Test'); } insertString(numberBox);` This case seems to be inconsistent with the example from the question. – ominug Dec 17 '17 at 13:25
  • ... see [my newly created TypeScript issue #20738](https://github.com/Microsoft/TypeScript/issues/20738). – ominug Dec 17 '17 at 14:12
  • Your `let x: ISetter = >y` example is using a type assertion, which is laxer than inference [intentionally](https://github.com/Microsoft/TypeScript/blob/master/doc/spec.md#416-type-assertions). Indeed, type assertions are only needed in an unsafe downcasting direction. – jcalz Dec 17 '17 at 20:11
  • The second example does not compile (with `--strictFunctionTypes` enabled) because it is enforcing contravariance on the function input, which is new since TSv2.6. There aren't many places that contravariance is enforced, which is why it differs from your example question. – jcalz Dec 17 '17 at 20:16
  • 1
    “Your `let x: ISetter = >y` example is using a type assertion, which is laxer than inference intentionally”—yes, but my concern was the assignment rather than the type assertion. (I had to use this because of the character limitation). Thank you for pointing me to `--strictFunctionTypes`—see [my answer](https://stackoverflow.com/a/47859340/1717752). – ominug Dec 17 '17 at 20:59
1

Since typescript 4.7, you can now explicitly specify type variance:

  • in made type contravariant
  • out made type covariant
  • in out made type invariant

Back to your question, you can just make IBox invariant:

interface IBox<in out T> {
  value: T;
}
undefined
  • 41
  • 1
  • 2
0

Since TypeScript 2.6 tsc has a command line option --strictFunctionTypes (which is automatically included with --strict). If given, this enforces contravariant type checking of the arguments of function types. Methods and constructors are excluded from this rule by design for compatibility reasons. So to employ contravariance, it seems to be required to use function types:

interface IBox<T> {
  getValue: () => T;
  setValue: (value: T) => void;
}

class Box<T> implements IBox<T> {
  private value: T;
  public constructor(value: T) {
    this.value = value;
  }
  public getValue() {
    return this.value;
  }
  public setValue(value: T) {
    this.value = value;
  };
}

const numberBox: IBox<number> = new Box<number>(1);

function insertString(items: IBox<string | number>): void {
  items.setValue('Test');
}

// this call does not compile anymore
insertString(numberBox);

If IBox is replaced by Box in the last 3 statements, contravariant type checking does not apply. So to prevent someone from accidentially referencing this class type, Box<T> and IBox<T> could be placed in a separate ES6 module exposing only the interface and a factory function.

ominug
  • 1,422
  • 2
  • 12
  • 28