Skip to content

Improved excess property checks in union types / new paradigm: use literal type infered from literal initializer #42997

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

Closed
5 tasks done
craigphicks opened this issue Feb 27, 2021 · 4 comments
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript

Comments

@craigphicks
Copy link

craigphicks commented Feb 27, 2021

Suggestion

🔍 Search Terms

  • strict excess property checks in union types.
  • union type calculation
  • literal type inference from literal initializer

List of keywords you searched for before creating this issue. Write them down here so that others can find this suggestion more easily and help provide feedback.

✅ Viability Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

Consider the statement:

type T1 = {<some props>}
type T2 = {<some props>}
type T3 = {<some props>}
type TU=T1|T2|T3
SomeTypeDef<T> = ...
const t:SomeTypeDef<TU> = {a:1,b:2}

The last line is an assignment statement. The processing taking place in the assignment has two distinct and separate parts:

  • The left hand side in isolation, which is the type function SomeTypeDef with the single input variable TU.
  • Determining the validity of the assignment of the r.h.s. literal initializer {<some props>} to the l.h.s type. That computation takes place using Typescript's fixed assignment rules that cannot be changed.

Now suppose we define an additional type

type I = {a:1,b:2}

which you will notice is the type of the literal initializer on the r.h.s. of the assignment. Now suppose we add that type as an additional variable to a type function on the l.h.s.:

const t:SomeTypeDefPlus<TU,I> = {a:1,b:2}

Now the l.h.s type function has additional information to work with . Therefore whatever SomeTypeDef<TU> can express, SomeTypeDefPlus<TU,I> can also express in the same length code. However SomeTypeDefPlus<TU,I> may express more things than SomeTypeDef<TU>, and/or may be able to express the same things in shorter code. In psuedo-psuedo code:

Expressability(SomeTypeDefPlus<TU,I>) >= Expressability(SomeTypeDef<TU>)

Unfortunately

  • writing the type type I = {<some props>}, AND
  • and writing the r.h.s literal initializer .... = {<some props>}

is twice as much writing - a code length penalty.

The proposal is to enable the type of the r.h.s. literal initializer to be inferred, e.g.,

const t:SomeTypeDefPlus<TU,I> = {a:1,b:2} as infer literal I

or any other syntax that would automatically set the value of I on the l.h.s. to be type I = {a:1,b:2}

Is there really any need for a SomeTypeDefPlus<TU,I> functionality? Yes. motivating function below explains that
it makes it easier to write a generalized function for strict excess property checking in type unions .

📃 Motivating Example

Typescript Playground

// c.f. https://github.com/microsoft/TypeScript/issues/42997
// craigphicks Feb 2021
//-----------------------
// TYPES
type T1 = {a:number,b:number}
type T2 = {a:number,c:number}
type T3 = {a:string,c?:number}
type T4 = {a:bigint, [key:string]:bigint}
type T5 = {a:string, d:T1|T2|T3|T4}
type T12 = T1|T2|T3|T4|T5
//-----------------------
// TYPES INFERRED FROM THE INITIALIZER 
type I0 = {}
type I1 = {a:1,b:1}
type I2 = {a:1,c:1}
type I3 = {a:1,b:1,c:1}
type I4 = {a:1}
type I5 = {a:'2',c:1}
type I6 = {a:'2'}
type I7 = {a:1n, 42:1n}
type I8 = {a:'1', d:{a:1n, 42:1n}}
type I9 = {a:'1', d:{}}
//-----------------------
// THE CODE 
type Select<T,I>= {[P in keyof I]: P extends keyof T ?
  (T[P] extends object ? ExclusifyUnionPlus<T[P],I[P]> : T[P]) : never} 
type ExclusifyUnionPlus<T,I>= T extends any ? (I extends Select<T,I> ? T : never):never
//-----------------------
// case specific type aliases
type DI<I>=ExclusifyUnionPlus<T12,I>
// special types for se question https://stackoverflow.com/q/46370222/4376643
type sth = { value: number, data: string } | { value: number, note: string };
type DIsth<I>=ExclusifyUnionPlus<sth,I>
//-----------------------
// THE TESTS - ref=refuse, acc=accept
const sth0:DIsth<{ value: 7, data: 'test' }>={ value: 7, data: 'test' }; // should acc
const sth1:DIsth<{ value: 7, note: 'test' }>={ value: 7, note: 'test' }; // should acc
const sth2:DIsth<{ value: 7, data:'test', note: 'hello' }>={ value:7, data:'test',note:'hello' }; // should ref
type DI0=DI<I0> ; const d0:DI0={} // should ref
type DI1=DI<I1> ; const d1:DI1={a:1,b:1} // T1, should acc
type DI2=DI<I2> ; const d2:DI2={a:1,c:1} // T2, should acc
type DI3=DI<I3> ; const d3:DI3={a:1,b:1,c:1} // should ref
type DI4=DI<I4> ; const d4:DI4={a:1} // should ref
type DI5=DI<I5> ; const d5:DI5={a:'2',c:1}  // T3, should acc
type DI6=DI<I6> ; const d6:DI6={a:'2'}  // T3, should acc
type DI7=DI<I7> ; const d7:DI7={a:1n,42:1n}  // T4, should acc
type DI8=DI<I8> ; const d8:DI8={a:'1',d:{a:1n,42:1n}}  // T5, should acc
type DI9=DI<I9> ; const d9:DI9={a:'1',d:{}}  // should ref
//-------------------
// Comparison with type function NOT using type of intializer
// Code from SE  https://stackoverflow.com/a/46370791/4376643
type AllKeys<T> = T extends unknown ? keyof T : never;
type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
type _ExclusifyUnion<T, K extends PropertyKey> =
    T extends unknown ? Id<T & Partial<Record<Exclude<K, keyof T>, never>>> : never;
type ExclusifyUnion<T> = _ExclusifyUnion<T, AllKeys<T>>;
//-------------------
// case specific alias
type SU=ExclusifyUnion<T12>
// tests
const sd0:SU={} // should ref
const sd1:SU={a:1,b:1} // should acc
const sd2:SU={a:1,c:1} // should acc
const sd3:SU={a:1,b:1,c:1} // should ref
const sd4:SU={a:1} // should ref
const sd5:SU={a:'2',c:1}  // should acc
const sd6:SU={a:'2'}  // should acc
const sd7:SU={a:1n,42:1n}  // should acc
const sd8:SU={a:'1',d:{a:1n,42:1n}}  // should acc
const sd9:SU={a:'1',d:{}}  // should ref
// Apparently ExclusifyUnion doesn't handle addtional property speficier in T4
// Also does it handle deep objects?  Have posted message to ExclusifyUnion author, awaiting reply.

Two criteria by which to compare ExclusifyUnion and ExclusifyUnionPlus -

  • Ease and clarity:
  • Total range of expression:

As for 'ease and clarity' , ExclusifyUnionPlus does seems easier to code and comprehend.
As for 'total range of expression', I am not sure. It would require a proof.

💻 Use Cases

The proposed syntax

const t:SomeTypeDefPlus<TU,I> = {a:1,b:2} as infer literal I

or something like it would simplify the leveraging existing functionality for the purposes of strictly detecting excess properties in literal assignments to union of Record types. That includes deep Record types with deep member which are themselves unions of Record types.

It could also have other uses besides detecting excess properties.

Reference

This suggestion is based on an answer I posted on SE to a question about excess property checks.

@craigphicks craigphicks changed the title Improved excess property checks in union types / new paradigm: type inference from declaration Improved excess property checks in union types / new paradigm: use literal type infered from literal initializer Feb 28, 2021
@RyanCavanaugh
Copy link
Member

This sounds a lot like #7481 - comments?

@RyanCavanaugh RyanCavanaugh added the Needs More Info The issue still hasn't been fully clarified label Mar 11, 2021
@craigphicks
Copy link
Author

One takeaway from issue #7481 is that it proposes an is operator the would provide a strict-cast version of as:

some_literal is T
. will be accepted if and only if
const x:T = some_literal
. is accepted

Further down, it is suggested that is be combined const as in

const x = {a:1,b:[{c:1}]} as const is X

These are related to the use case I meant to propose, but even if is is implemented, it is not exactly what is needed.

The use case I am concerned with is an in-place strict-union operation to allow for deep excess property checking. I am calling it an 'in-place deep strict-union operation' and not a 'strict-union type defintion` to make clear it is not a repeat of the request for 'exact types'. I have read the comment on why exact types conflict with the existing definition of types, in particular

We have some basic tenets that exact types would invalidate. For example, it's assumed that a type T & U is always assignable to T, but this fails if T is an exact type. This is problematic because you might have some generic function that uses this T & U -> T principle, but invoke the function with T instantiated with an exact type.

and agree completely.

How this differs is that it requires no change to Typescript defintion of types. In fact the in-place deep strict-union is already possible(*) using type functions such as distributed conditional type operations. (*Possible except for the fact that that an 'possibly infinite depth error' is falsely emitted, and the type function silently stops working when the number of data reaches a certain size .)

Let me demonstate:

type T1 = {a: string; c?: number};
type T2 = {[key: string]: bigint};
type T3 = {a: bigint; b: number};

// add tuple types
type Ttpl1 = [number, string];
type Ttpl2 = [T1 | T2 | T3];

type TU = T1 | T2 | T3 | Ttpl1 | Ttpl2;

const testData: SU<TU[], Iarr> = [
  [{a: '1', c: 1}], // accept  ✔
  [{a: 1n}], // accept  ✔
  [{a: 1n, b: 1n}], // accept  ✔
  [{a: 1n, c: 1n}], // accept  ✔
  [{a: 1n, b: 1}], // accept  ✔

  //  excess props of other types
  [{a: '1', b: 1n, c: 1}], // reject  ✔
  [{a: '1', c: 1, d: 1n}], // reject  ✔
  [{a: '1', d: 1n}], // reject  ✔
  [{a: 1n, b: 1, c: 1}], // reject  ✔
  [{a: 1n, b: 1, d: 1n}], // reject  ✔

  // insufficinet props
  [{a: 1}], // reject  ✔
];

Note1: definitions for SU and Iarr are missing in the above snippet.

Note2: SU<TU[], Iarr> issues an error "Type instantiation is excessively deep and possibly infinite.ts(2589)" but still processes the rest of the example code anyway.

In this typical use case an array of testData is populated with items
each of which the programmer desires to belong exactly to one of the union members of TU,
i.e. no excess properties at any finite depth.

The definition for Iarr is simply

type Iarr = [
  [{a: '1', c: 1}], // accept
  [{a: 1n}], // accept
  [{a: 1n, b: 1n}], // accept
  [{a: 1n, c: 1n}], // accept
  [{a: 1n, b: 1}], // accept

  //  excess props of other types
  [{a: '1', b: 1n, c: 1}], 
  [{a: '1', c: 1, d: 1n}], 
  [{a: '1', d: 1n}], 
  [{a: 1n, b: 1, c: 1}], 
  [{a: 1n, b: 1, d: 1n}], 

  [{a: 1}]
];

The point of this present issue is to suggest that Iarr shouldn't need to be
manually entered again, because it was already entered after const testData.

Looking at issue #7481 inspires the following idea:

type TT1 = Readonly<{ a:string, c:string }>
type TT2 = Readonly<{ a:number, b:number }>
const testData2:SU<TT1|TT2, typeof testData2> = [
  {a: '1', c: '1'}, 
  {a: 1, b: 1},
] as const; 

However, that emits the following errors:

  • 'testData2' is referenced directly or indirectly in its own type annotation.ts(2502)
  • Block-scoped variable 'testData2' used before its declaration.ts(2448)

The "working" (excepting problems noted above) code for the strict-union function SU with the above example testData
is show in this Typescript playground and posted in full in the next comment.

@craigphicks
Copy link
Author

type IdArray_next_aux<ELT, I extends [any, ...unknown[]]> = I extends [
  infer HI,
  ...infer RI
]
  ? [SU<ELT, HI>, ...IdArray_next<ELT, RI>]
  : ['I doesnt extends [infer HI, ...infer RI]'];

type IdArray_next<ELT, I> = I extends [] | never[] | [never] //I extends [any, ...unknown[]]
  ? []
  : I extends [any, ...unknown[]]
  ? IdArray_next_aux<ELT, I>
  : ['unexpected error 0'];

type IdArray<T, I> = T extends (infer ELT)[]
  ? I extends [any, ...unknown[]]
    ? IdArray_next<ELT, I>
    : ['I doesnt extends [any, ...unknown[]']
  : ['T doesnt extends (infer ELT)[]'];

type SU_tuple_next<T, I> = I extends [infer HI, ...infer RI]
  ? T extends [infer HT, ...infer RT]
    ? [SU<HT, HI>, ...SU_tuple_or_array<RT, RI>]
    : ['T doesnt extend [infer HT, ...infer RT]']
  : ['I  doesnt extend [infer HI, ...infer RI]'];

type SU_tuple_or_array<T, I> = T extends any
  ? IsArray<T> extends true
    ? IdArray<T, I> //['case IsArray<T> not implemented']
    : I extends []
    ? T extends []
      ? []
      : ['I is [] but T is not []']
    : T extends []
    ? ['I is not [] but T is []']
    : SU_tuple_next<T, I>
  : never;

type SU_object<U, I> = U extends any
  ? I extends U
    ? keyof I extends keyof U
      ? {
          [P in keyof I]: I[P] extends Record<string, any>
            ? SU<U[P], I[P]> //SU_object<U[P], I[P]>
            : I[P] extends U[P]
            ? I[P]
            : ['I[P] doesnt extend U[P]'];
        }
      : ['keyof I doesnt extend keyof U']
    : ['I doesnt extend U']
  : ['U doesnt extend any'];

// Add a slot in SU for Array
type IsArray<T> = T extends any[]
  ? number extends T['length']
    ? true
    : false
  : false;

type SU<U, I> = I extends any[]
  ? SU_tuple_or_array<U, I>
  : I extends Record<string, any>
  ? SU_object<U, I>
  : I extends U
  ? I
  : ['I does not extend U']; // return U

type T1 = {a: string; c?: number};
type T2 = {[key: string]: bigint};
type T3 = {a: bigint; b: number};

// add tuple types
type Ttpl1 = [number, string];
type Ttpl2 = [T1 | T2 | T3];

type TU = T1 | T2 | T3 | Ttpl1 | Ttpl2;

//Type instantiation is excessively deep and possibly infinite.ts(2589)
// But it works anyway, until it doesn't
const testData: SU<TU[], Iarr> = [
  [{a: '1', c: 1}], // accept  ✔
  [{a: 1n}], // accept  ✔
  [{a: 1n, b: 1n}], // accept  ✔
  [{a: 1n, c: 1n}], // accept  ✔
  [{a: 1n, b: 1}], // accept  ✔

  //  excess props of other types
  [{a: '1', b: 1n, c: 1}], // reject  ✔
  [{a: '1', c: 1, d: 1n}], // reject  ✔
  [{a: '1', d: 1n}], // reject  ✔
  [{a: 1n, b: 1, c: 1}], // reject  ✔
  [{a: 1n, b: 1, d: 1n}], // reject  ✔

  // insufficinet props
  [{a: 1}], // reject  ✔
];

type Iarr = [
  [{a: '1', c: 1}], // accept
  [{a: 1n}], // accept
  [{a: 1n, b: 1n}], // accept
  [{a: 1n, c: 1n}], // accept
  [{a: 1n, b: 1}], // accept

  //  excess props of other types
  [{a: '1', b: 1n, c: 1}], 
  [{a: '1', c: 1, d: 1n}], 
  [{a: '1', d: 1n}], 
  [{a: 1n, b: 1, c: 1}], 
  [{a: 1n, b: 1, d: 1n}], 

  [{a: 1}]
];

@RyanCavanaugh RyanCavanaugh added Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript and removed Needs More Info The issue still hasn't been fully clarified labels Mar 12, 2021
@craigphicks
Copy link
Author

I think that modifying the typescript distributed-types meta-language to be more robust is probably not a practical suggestion.

A better way would be to utilize the typescript API or other typescript utilities to create a program that allows customized behavior layered over typescript. That is how linting works - decoupled.

Unlike linting, however, deep excess property checking would require the symbols from compilation. The typescript API provides such an interface. That would enabled development decoupled from the main typescript release. It would be the best way to progress.

Therefore I will close this issue, as the design of such decoupled downstream development is not material for an issue.

Incidentally, I have submitted another issue #44871, part of which requests clarification on the actual intended rules for excess type checking. Because hard specification for such rules (even if they may change in the future) comprise an interface, and therefore are a requirement for decoupled downstream development, 44871 is an issue that I hope can be resolved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

2 participants