Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Suggestion: Uniform Type Predicate, or Concrete Types #28430

Open
jack-williams opened this issue Nov 9, 2018 · 11 comments
Open

Suggestion: Uniform Type Predicate, or Concrete Types #28430

jack-williams opened this issue Nov 9, 2018 · 11 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@jack-williams
Copy link
Collaborator

jack-williams commented Nov 9, 2018

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); // ok

Examples from related issues (1)

#27808

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)

#24085

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.
@weswigham weswigham added Suggestion An idea for TypeScript In Discussion Not yet reached consensus labels Nov 9, 2018
@jack-williams
Copy link
Collaborator Author

I’m thinking of having a stab at implementing this, probably over the holidays. Would anyone be interested?

@jack-williams
Copy link
Collaborator Author

jack-williams commented Dec 26, 2018

Initial ramblings here

@mmis1000
Copy link

mmis1000 commented 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 fail

So, force the type parameter to extend its transformed union will guard the unions away.

const state = {
  text: "",
  count: 0,
};

type State = Readonly<typeof state>;
type UnionToIntersection<U> = 
  (U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never
  
function isEqual<T extends string>(a: T, b: any): b is T {
    return a === b
}

const changeCount = <T extends (keyof State & UnionToIntersection<T>), U extends State[T] = State[T]>(name: T, value: U) => {};

changeCount("text", "") // yes
changeCount("count", 1) // yes
changeCount("count" as ("count" | "text"), 1) // no

@jack-williams
Copy link
Collaborator Author

jack-williams commented Jan 10, 2019

@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
Copy link
Collaborator Author

Related: #22628

@jack-williams
Copy link
Collaborator Author

For anyone interested: I'm going to work on a modified proposal based on some initial work I've tried out.

@jack-williams
Copy link
Collaborator Author

Prototype here: #30284

@timargra
Copy link

timargra commented 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:

type Cat = { legs: 4, makes: 'meow', lives: number };
type Dog = { legs: 4, makes: 'wuff' };
type Pet = Cat | Dog;

function killACat(pet1: Pet, pet2: Pet) {
   switch (pet1.makes) {
       case 'meow':
           pet1.lives > (pet2 as typeof pet1).lives ? pet1.lives-- : (pet2 as typeof pet1).lives--;
           break;
   }
}

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
Copy link
Collaborator Author

How do you rule out call-sites like?

const a: Pet = { legs: 4, makes: 'meow', lives: 9 };
const b: Pet = { legs: 4, makes: 'wuff' };
killACat(a, b);

From the checker POV b has the same types as a. It's not possible to retroactively go back and see that a was a cat and b was a dog.

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented 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
Copy link
Collaborator Author

jack-williams commented 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?”

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

5 participants