Repository navigation
Suggestion: Uniform Type Predicate, or Concrete Types #28430
Description
Suggestion: Uniform Type Predicate, or Concrete Types
Summary
Narrowing tells us information about a value but not so much about the type of the value, this is because a type may over approximate a value. The over approximation poses a problem when using multiples values of the same type: narrowing information is not sharable because over approximation can allow non-uniform behaviour with respect to operations such as typeof or ===.
As a canonical example:
function eq<T>(x: T, y: T) {
if (typeof x === "number") {
// What do we know about y here?
}
}Inside the if branch what do we know about y, a variable with the same type as x that we know to be a number. Sadly, nothing. A caller may instantiate T to be unknown and therefore y could be any other value.
eq<unknown>(5, "not a number");Proposal
Support a way to define uniform, or concrete, types. A uniform type is one where all values of that type behave uniformly with respect to some operation. The idea is taken from Julia, where concrete types are defined as:
A concrete type T describes the set of values whose direct tag, as returned by the typeof function, is T. An abstract type describes some possibly-larger set of values.
The obvious candidate of operation is typeof, but this could be extended to include equality for literal types, or key sets for objects.
Basic Example
The introduction syntax is very much up for grabs, but for the examples we can use a constraint.
function eq<T extends Concrete<"typeof">>(x: T, y: T) {
if (typeof x === "number") {
// if x is number, then so is y.
}
}The constraint Concrete<"typeof"> of T says that T may only be instantiated with types where all values of that type behave uniformly with respect to typeof. When x is a number, then so is y. The following call-sites demonstrate legal/illegal instantiations.
eq<unknown>(5, "not a number"); // Illegal: unknown is not concrete
eq<number>(5,4); // ok
eq<number | string>(5, "not a number also"); // Illegal, (number | string) is not concrete.
eq<4 | 2>(4, 2); // okExamples from related issues (1)
declare function smallestString(xs: string[]): string;
declare function smallestNumber(x: number[]): number;
function smallest<T extends Concrete<number | string, "typeof">>(x: T[]): T {
if (x.length == 0) {
throw new Error('empty');
}
const first = x[0]; // first has type "T"
if (typeof first == "string") {
return smallestString(x); // legal
}
return smallestNumber(x);
}We write Concrete<number | string, "typeof"> for a type that is either a string or number, but is also concrete. As the values of the array are concrete, a single witness for typeof is enough to narrow the type of the entire array.
Examples from related issues (2)
Here we show a use case for defining uniformity of equality.
const enum TypeEnum {
String = "string",
Number = "number",
Tuple = "tuple"
}
interface KeyTuple { key1: string; key2: number; }
type KeyForTypeEnum<T extends TypeEnum>
= T extends TypeEnum.String ? string
: T extends TypeEnum.Number ? number
: T extends TypeEnum.Tuple ? KeyTuple
: never;
function doSomethingIf<TType extends Concrete<TypeEnum, "===">>(type: TType, key: KeyForTypeEnum<TType>) {
if (type === TypeEnum.String) {
doSomethingWithString(key);
}
else if (type === TypeEnum.Number) {
doSomethingWithNumber(key);
}
else if (type === TypeEnum.Tuple) {
doSomethingWithTuple(key);
}
} The issue presented here is that over-approximation leads to unsound uses:
doSomethingIf<TypeEnum>(TypeEnum.String, 42);However with a concrete constraint Concrete<TypeEnum, "==="> we enforce that the parameter is assignable to TypeEnum, however any instantiation must be uniform with respect to equality, restricting instantiations to singleton types. Example calls:
doSomethingIf<TypeEnum>(TypeEnum.String, 42); // Illegal: TypeEnum is not concrete
doSomethingIf<TypeEnum.String>(TypeEnum.String, "foo"); // Ok
doSomethingIf<TypeEnum.String | TypeEnum.Number>(TypeEnum.String, 42); // Illegal: (TypeEnum.String | TypeEnum.Number) is not concrete.
Activity
jack-williams commentedon Dec 16, 2018
I’m thinking of having a stab at implementing this, probably over the holidays. Would anyone be interested?
jack-williams commentedon Dec 26, 2018
Initial ramblings here
mmis1000 commentedon Jan 6, 2019
I though the purpose of guarding union type may be possible by some hack in current version of typescript (3.2.2).
By abusing the type inference of ts
https://stackoverflow.com/questions/50374908/transform-union-type-to-intersection-type
There is some trick exists to cast union to intersection
Eg.
{a: 1} | {b: 2}=>{a: 1} & {b: 2}And union never extends its transfromed intersection
({a: 1} | {b: 2}) extends ({a: 1} & {b: 2})will failSo, force the type parameter to extend its transformed union will guard the unions away.
jack-williams commentedon Jan 10, 2019
mmis1000 (@mmis1000) There are some workarounds to some of the issues, but these don't really solve the underlying problem.
The union to intersection trick does not work for many cases, including booleans. The main problem is that it only superficially helps by preventing you calling the function with arguments with union type. What you really want is for the compiler to be able to exploit this in the body function; to be able to do smarter reasoning and narrowing. I'm not sure the compiler is ever going to be able to exploit a constraint such as
UnionToIntersection<T>to do the reasoning we want.jack-williams commentedon Jan 23, 2019
Related: #22628
jack-williams commentedon Feb 3, 2019
For anyone interested: I'm going to work on a modified proposal based on some initial work I've tried out.
jack-williams commentedon Mar 9, 2019
Prototype here: #30284
timargra commentedon May 28, 2019
I just stubled over this limitation and thought the following approach could be a clean solution with minimal potential for unwanted side-effects:
Currently, within the function body, you can use explicit type assertion to make union type guards work under the assumtion that the type of argument b is identical to the type of argument a:
However, it should be possible to use the same notation right in the declaration:
function killACat(pet1: Pet, pet2: typeof cat1) { ... }Currently, this doesn't work, but I see no logical reason why it shouldn't
jack-williams commentedon May 28, 2019
How do you rule out call-sites like?
From the checker POV
bhas the same types asa. It's not possible to retroactively go back and see thatawas a cat andbwas a dog.RyanCavanaugh commentedon May 31, 2019
Probably the relevant observation is that "Are these two values of the same type?" is not an meaningfully answerable question in a type system like TypeScript's. The only meaningful questions are more of the form "Are these two values both in this type's domain?". Even "Which type (if any) subsumes both of these values?" is one with many possible correct answers.
jack-williams commentedon May 31, 2019
Yep! This suggestion is really just about being able the restrict types such that run time checks can give us meaningful and unique answers to questions such as: “Which (useful) type subsumes both of these values, or all values of this type?”