commented: Can "Unconvention 2" be simplified down to the following? const mutation = <A, B,>(a: A, b: B): B => { const obj = { field: b }; const obj2: { field: A | B } = obj; obj2.field = a; return obj.field; }; No need for an inner function. commented: That it can! Good demonstration that the variance really is the root cause, not any cross-function stuff. commented: The soundness problems in TypeScript were why I was always rooting for Flow. For example: "Unconvention 2" is caught by Flow "Unconvention 3" is caught by Flow "Unconvention 4" is caught by Flow Flow had its own problems (e.g., the editor tooling for Flow compared disfavorably to TypeScript), but soundness was always squarely a win in Flow. commented: My sense is that most sources of unsoundness in TypeScript can be exploited into an unsafe cast. Here's one using Object.assign and never type. function cast<A, B>(value: A): B { return Object.assign( { field: () => { throw new Error(); } }, { field: () => value }, ).field(); } commented: That is, in some sense, the definition of unsoundness—whether you can treat one type as any other type, i.e. whether the types are preserved as you make progress evaluating the program. But note that this example is not actually evidence of unsoundness. Your program can be simplified to function cast<A, B>(value: A): B { throw new Error(); } This does not pose any problems in a type system as far as soundness goes, because this is not a true "cast": it does not introduce a value of type B that witnesses the unsoundness. commented: The example does work, actually; The "target" field() is overwritten by the implementation of the "source" field(), so when .field() runs it calls the second function. The reason why the types work out is because Object.assign creates an object of type { field: () => never } & { field: () => A }, meaning field has type (() => never) & (() => A), and Typescript has a hell of a time resolving what the return type of that call is. Really, it should reject it, but it doesn't, ending up with just never instead ¯\_(ツ)_/¯ commented: ah yep! Thanks for the correction commented: Is there any real consensus on what the goal of a type system is? Because very clearly, “unsound” systems like the TypeScript type system, or the Python type system, are still useful for helping catch errors in code. And also in the other direction—one way of defining the purpose of type systems is to say they classify program values in order to ensure “wrong” things in the language don’t happen. But then what do newtypes do? They prevent errors, but these errors aren’t formally errors. Is there some theory about what type systems actually do? commented: are still useful for helping catch errors in code. But then what do newtypes do? They prevent errors, but these errors aren’t formally errors. These are absolutely part of what makes a type system useful, but I think what you're missing is that they're even more general than that: Is there any real consensus on what the goal of a type system is? The explanation I personally like best is that a type-system codifies the developer's intent. And depending on how advanced the system is, this codification can be either very simple ("This is a number" / "I intend to give this function strings and get back numbers") or very advanced ("This function will cause an IO side-effect" / "This value can('t) be freely shared between threads"), but the end result is the same: Providing a unified means for both tools and people to understand how parts fit together and how code flows without needing to comprehend the entire code-base / read a documentation that might be outdated or not even exist. .