Skip to content

Uniform Generics for Conditional Type Inference and Type Parameter Narrowing [Experiment] - #30284

Closed
jack-williams wants to merge 26 commits into
microsoft:mainfrom
jack-williams:uniform-types
Closed

Uniform Generics for Conditional Type Inference and Type Parameter Narrowing [Experiment]#30284
jack-williams wants to merge 26 commits into
microsoft:mainfrom
jack-williams:uniform-types

Conversation

@jack-williams

@jack-williamsjack-williams commented Mar 9, 2019

Copy link
Copy Markdown
Collaborator

This PR is an experimental implementation of uniform generics: Generic types that can only be instantiated with types that behave uniformly under typeof. This additional constraint makes it possible to apply new reasoning: something abit closer to dependent types.

  • Narrowing via typeof applies to all values of a uniform type. See equalityTransUniform.
  • Conditional types can be inferred from ternary expressions. See fn.

The notation for uniform generic types is:

functionf<T!>(x: T,y: T){
...
}

Type parameter T must be instantiated with a type that behaves uniformly under typeof, such as number, boolean, 1 | 2 | 3, but notnumber | boolean, any, or unknown.

This feature significantly benefits from #29317 and #29437.

The feature can also be extended to further uniformity constraints such as equality for enum members.

Examples (including ones from #22735 and #24929):

interfaceDependentPair<T!>{x: T;y: Textendsstring ? 1 : 0;}functiondependentPair<T!>(mutual: DependentPair<T>){letone: 1=mutual.y;// error;constx=mutual.x;if(typeofx==="string"){one=mutual.y;// ok}returnone;}/* * Infer conditional type. Propagate types between x1 and x2. */functionfn<T!extendsstring|number|boolean>(x1: T,x2: T): If<T,string,"s",If<T,number,"n","b">>{if(typeofx1==='string'&&typeofx2==='number'){returnx1;// never, because this can never happen!}returntypeofx1==="string" ? "s" : typeofx2==="number" ? "n" : "b";}consttester1: "n"=fn<number>(3,5)consttester2: "b"=fn<boolean>(true,false)consttester3: "s"=fn<string>("hello","world");consttester4: unknown=fn<string|number|boolean>(3,"world");// error Type 'string | number | boolean' does not satisfy uniformity constraint of type 'T'. Values of type 'string | number | boolean'' do not behave identically under typeof [2752]constenumTypeEnum{String="string",Number="number",Tuple="tuple"}/* Example using uniform equality */declarefunctiondoSomethingWithString(key: string): void;declarefunctiondoSomethingWithNumber(key: number): void;declarefunctiondoSomethingWithTuple(key: KeyTuple): void;interfaceKeyTuple{key1: string;key2: number;}typeKeyForTypeEnum<TextendsTypeEnum>=TextendsTypeEnum.Number ? number
: TextendsTypeEnum.String ? string
: TextendsTypeEnum.Tuple ? KeyTuple
: never;functiondoSomethingIf<TType~extendsTypeEnum>(type: TType,key: KeyForTypeEnum<TType>){if(type===TypeEnum.Number){// key has type KeyForTypeEnum<TType & TypeEnum.Number>,// so we resolve the conditional typereturndoSomethingWithNumber(key);}if(type===TypeEnum.String){// key has type KeyForTypeEnum<TType & TypeEnum.String>// so we resolve the conditional typereturndoSomethingWithString(key);// ok}if(type===TypeEnum.Tuple){// key has type KeyForTypeEnum<TType & TypeEnum.Tuple>// so we resolve the conditional typereturndoSomethingWithTuple(key);// ok}}doSomethingIf(TypeEnum.String,"hello");// okdoSomethingIf<TypeEnum>(TypeEnum.String,42);// error: Type 'TypeEnum' does not satisfy uniformity constraint of type 'TType'./* * Infer conditional type. */functioncapitalize<T!extendsstring|string[]>(input: T): If<T,string,string,string[]>{returntypeofinput==="string" ?
(input[0].toUpperCase()+input.slice(1)) :
(<T&string[]>input).map(elt=>capitalize(elt));// cast needed without negated types}consts: string=capitalize("hello");/* * Type is inferred as If<T, string, T & string, null> */functionensureString<T!>(bar: T){returntypeofbar==="string" ? bar : null;}consts3: string=ensureString("");// correctly inferred as stringconstnull1: null=ensureString(1);// correctly inferred as nulldeclarefunctioncompareN(x: number,y: number): boolean;declarefunctioncompareB(x: boolean,y: boolean): boolean;functionequalityTrans<T>(x: T,y: T): boolean{if(typeofx==="number"){returncompareN(x,y);// error}if(typeofx==="boolean"){returncompareB(x,y);// error}returnfalse;}functionequalityTransUniform<T!>(x: T,y: T): boolean{if(typeofx==="number"){returncompareN(x,y);// ok: x and y are both T & number}if(typeofx==="boolean"){returncompareB(x,y);// ok x and y are both T & boolean}returnfalse;}/** * Uniformity constraints prevent bad instantiations */equalityTransUniform(true,3)// errorequalityTransUniform<boolean|number>(true,3)// error: Type 'number | boolean' does not satisfy uniformity constraint of type 'T'. Values of type 'number | boolean' do not behave identically under typeof [2752]equalityTransUniform<unknown>(true,3)// error: Type 'unknown' does not satisfy uniformity constraint of type 'T'. Values of type 'unknown' do not behave identically under typeof [2752]equalityTransUniform<any>(true,true)// errorequalityTransUniform(true,true)// okfunctionconditionalNarrow<T!>(x: T,cond: [T]extends[number] ? {x: string} : {y: string}){if(typeofx==="number"){returncond.x}// need negation types// return cond.yreturn"foo";}conditionalNarrow(3,{x: 'hello'});conditionalNarrow(true,{y: 'world'});conditionalNarrow(3,{y: 'world'});// errorconditionalNarrow<unknown>(3,{y: 'world'});// error/* * Inferred type is [T] extends [string] ? { x: T & string } : { y: T }; * or, If<T, string, { x: T & string }, { y: T }> */functionfoo<T!>(x: T){returntypeofx==="string" ? {x: x} : {y: x}}constx: {x: string}=foo('hello');consty: {y: boolean}=foo(false);

@jack-williamsjack-williams changed the title Experiment: Uniform Generics (Conditional Type Inference and Type Parameter Narrowing)Uniform Generics for Conditional Type Inference and Type Parameter Narrowing [Experiment]Mar 9, 2019
@RyanCavanaughRyanCavanaugh added the Experiment A fork with an experimental idea which might not make it into master label Apr 25, 2019
@rubenpieters

rubenpieters commented Jun 7, 2019

Copy link
Copy Markdown

Could you expand a bit on how the following example works with your implementation?

functiontest1<T!extendsnumber|boolean>(b: T): T{if(typeofb==="boolean"){// unsafe, since T could be falsereturntrue;}returnb;}

Does it reject this program? It should be rejected, since if it is accepted it results in unsoundness if T is instantiated as false.

constb=test1<false>(false);// b === true while it has the type false

@jack-williams

Copy link
Copy Markdown
CollaboratorAuthor

The test1 function would still fail to type check: this feature still doesn't let you assign concrete values to generic things (a.k.a creating lower bounds on type parameters). In the if branch the type b would be narrowed to T & boolean, but this would still not allow true to be assigned because it doesn't satisfy the type T. Even when the type parameter is constrained to a singleton type, creating a lower bound is abit dubious.

The callsite const b = test1<false>(false); would type-check because the type false satisfies the uniformity constraint, but it's the body that would fail.

@rubenpieters

Copy link
Copy Markdown

Yes, that makes sense. I was just curious to see how it worked.

Even when the type parameter is constrained to a singleton type, creating a lower bound is abit dubious.

Do you have an example in mind where it would be unsound?

@jack-williams

Copy link
Copy Markdown
CollaboratorAuthor

It depends on your definition of unsound - it's unlikely that code today would go observably wrong. The technical issue is that it violates parametricity, and various nice things you could learn from reading a type no longer apply. For instance.

declarefunctionswap<Textendstrue>(t: readonly[true,T]): readonly[T,true];

One assertion that holds today might be that swap(x) !== x for all x because the input is readonly and you have to swap the input to get a T in the first position. If you allowed lower bounds this wouldn't be true:

functionswap<Textendstrue>(t: readonly[true,T]): readonly[T,true]{if(t[1]===true){returnt;// as true <: T, and T <: true, therefore [true, T] <: [T, true]}return[t[1],t[0]];}

I also generates issues if you ever add name subtyping, where you could have a named subtype like flow.

opaquetype valid: true;// valid is a subtype of true, but true is not a subtype of valid

The claim then that narrowing a generic T using === true to yield true <: T would be false because T could be valid.

@sandersn

Copy link
Copy Markdown
Member

This experiment is pretty old, so I'm going to close it to reduce the number of open PRs.

@typescript-bot

Copy link
Copy Markdown
Contributor

This PR doesn't have any linked issues. Please open an issue that references this PR. From there we can discuss and prioritise.

@microsoftmicrosoft locked as resolved and limited conversation to collaborators Oct 21, 2025
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

ExperimentA fork with an experimental idea which might not make it into master

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

@jack-williams@rubenpieters@sandersn@typescript-bot@RyanCavanaugh