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

Exact intersection type support #419

Closed
JasonShin opened this issue Jul 7, 2022 · 7 comments · Fixed by #421
Closed

Exact intersection type support #419

JasonShin opened this issue Jul 7, 2022 · 7 comments · Fixed by #421

Comments

@JasonShin
Copy link

JasonShin commented Jul 7, 2022

I was playing with @zorji 's Exact type and found that it errors for intersection types, but specifically when you construct the intersection type like Array<{ a: string }> & Array<{ b: string }>

I can only reproduce the error in the following scenario:

type Type = Array<{ test: string }> & Array<{ z: number }>
// Can't construct Exact type with errors saying it's trying to also match on array method 'pop' and etc etc
const fn = <T extends Exact<Type, T>>(args: T) => args;

// correct input
fn([{
  code: 'test',
  name: '1'
}])

// missing field
fn([{
  code: 'test'
}])

// incorrect type
fn([{
  code: 'test',
  name: 1
}])

// excess field
fn([{
  code: 'test',
  name: '1',
  excessField: '',
}])

As per https://www.reddit.com/r/typescript/comments/m5wvrx/how_to_extract_proper_intersection_type_from_an/ and microsoft/TypeScript#43267

It seems like a workaround for those who have control over type declarations would be writing the intersection type like following:

type Type = ({ code: string } & { name: string })[]
const fn = <T extends Exact<Type, T>>(args: T) => args;

// correct input
fn([{
  code: 'test',
  name: '1'
}])

// missing field
fn([{
  code: 'test'
}])

// incorrect type
fn([{
  code: 'test',
  name: 1
}])

// excess field
fn([{
  code: 'test',
  name: '1',
  excessField: '',
}])

The only problem is when the user doesn't have control over the underlying module's type declaration but they are providing the weird intersection type that goes Array<{ ... }> & Array<{...}>, then they will get the error when trying to use Exact

@JasonShin JasonShin changed the title Exact type intersection type support Exact intersection type support Jul 7, 2022
@JasonShin
Copy link
Author

JasonShin commented Jul 7, 2022

I've attempted the solutions proposed in #259, the one from @adam-thomas-privitar and one that I proposed in the PR. I don't think matching on array pattern and recursive Exact is the solution as Exact on an array type input works already and these solutions result in circular constraint

@zorji
Copy link
Contributor

zorji commented Jul 8, 2022

Hi @JasonShin

I tried to find a solution but I couldn't make it work.

However, after some thought about it, I feel there is something wrong.

An intersection type of 2 object type makes sense.

e.g.

type X = { x: string }
type Y = { y: number }

type XY = X & Y // => equivalent to { x: string; y: number }

However, for array type

type ArrX = Array<{ x: string }>
type ArrY = Array<{ y: number }>

type ArrXY = ArrX & ArrY // => doesn't feel right that it is equivalent to Array<{ x: string; y: number }>
const xy = [{ x: '', y: 1 }] // however, TypeScript says yes, both field are merged

Another example I am not sure what's the correct result is

type ObjX = { value: { x: string } }
type ObjY = { value: { y: number } }

type ObjXY = ObjX & ObjY // does this mean it's equivalent to { value: { x: string; y: number } }

const xy: ObjXY = {
  value: { x: '', y: 1 } // and it is, TypeScript requires both fields
}

I also found TypeScript sometimes doesn't behave consistently, e.g.

const test: Array<{ x: string }> & Array<{ y: string }> = []
test.forEach((item) => item.y) // TypeScript complains that item only has `x` and does not have `y`

I also found another discussion regards to this issue and it's unclear how it should be interpreted. microsoft/TypeScript#11961 (comment)

I'll continue try different ideas but I am afraid there might not be a solution.

@zorji
Copy link
Contributor

zorji commented Jul 8, 2022

I also found the | operator could be confusing too

For the follow type

type MyList = Array<Dog> | Array<Cat>

I am not sure whether I should interpret it as a List that accept both Dog and Cat or it must be either a list of Dog or list of Cat.

@zorji
Copy link
Contributor

zorji commented Jul 9, 2022

Hi @JasonShin

I attempted 2 approach to extract a node from an array and it seems they gives me different result.

type ArrayAnd = Array<{ x: string }> & Array<{ z: number }>

type ArrayNode1<T> = T extends Array<infer U> ? U : never // 1st approach, use infer
type ArrayNode2<T> = T extends Array<unknown> ? T[0] : never // 2nd approach, user T[0]

type Attempt1 = ArrayNode1<ArrayAnd> // => { z: number } , looks wrong
type Attempt2 = ArrayNode2<ArrayAnd> // => { x: string; z: number } , looks correct

I implement the Exact again with ArrayNode2 and it looks like it's working.

import { Primitive } from 'type-fest'
import { KeysOfUnion } from 'type-fest/source/internal'

type ArrayNode<T> = T extends Array<unknown> ? T[0] : never

type ExactObject<ParameterType extends object, InputType> = ({ [Key in keyof ParameterType]: ParameterType[Key] }
  & Record<Exclude<keyof InputType, KeysOfUnion<ParameterType>>, never>)

export type Exact<ParameterType, InputType> = ParameterType extends Primitive ? ParameterType
  : ParameterType extends Array<unknown> ? Exact<ArrayNode<ParameterType>, ArrayNode<InputType>>[]
    : ParameterType extends object ? ExactObject<ParameterType, InputType>
      // should never reach the type below as ALL types should be either primitive
      // or array or object. However, if TypeScript does introduce a new type, the statement below can guard it
      : never

type ParameterType = Array<{ x: string }> & Array<{ z: number }>
const fn = <T extends Exact<ParameterType, T>>(args: T) => args

// correct input
fn([{
  x: '',
  z: 1,
}])

// missing field
fn([{
  z: 1,
}])

// missing field
fn([{
  x: '',
}])

// incorrect type
fn([{
  x: 1,
  z: 1
}])

// excess field
fn([{
  x: '',
  y: '',
  z: 1
}])

It looks like it's working for your use case, could you help me verify whether this works for you too?

@JasonShin
Copy link
Author

JasonShin commented Jul 9, 2022

Thanks, @zorji, I just tested your updated Exact but it seems to break excess check against nested fields

{
   type ParameterType = Array<{ x: string }> & Array<{ z: number, d: { e: string } }>
   const fn = <T extends Exact<ParameterType, T>>(args: T) => args

  fn([{
    x: '',
    z: 1,
    d: {
       e: 'test',
       f: true // allows the excess field `f` for nested object 🤔 
     }
  }])

}

Also the updated type breaks nested object's excess fields check in general for non-intersection type

type Type = {
   body: {
     [k: string]: unknown;
     code: string;
     name?: string;
   };
};

const fn = <T extends Exact<Type, T>>(args: T) => args;

{
   const input = {body: {code: '', mmm: true}};  // mmm is allowed 🤔 
   fn(input);
}

Sorry, this is super tricky 🤯 . I will try fixing it later as well

@zorji
Copy link
Contributor

zorji commented Jul 9, 2022

@JasonShin opps, my bad, I missed something in the ExactObject type.

Please try with the one below

type ExactObject<ParameterType extends object, InputType> = ({ [Key in keyof ParameterType]: ExactObject<ParameterType[Key], InputType[Key]> }
	& Record<Exclude<keyof InputType, KeysOfUnion<ParameterType>>, never>)

@JasonShin
Copy link
Author

@zorji Perfect! It works great

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants