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

Alias type for indexing a generic object with with transform breaking change in 4.2+ (2nd issue) #44095

Closed
iPherian opened this issue May 14, 2021 · 9 comments
Labels
Design Limitation Constraints of the existing architecture prevent this from being fixed

Comments

@iPherian
Copy link

iPherian commented May 14, 2021

Bug Report

πŸ”Ž Search Terms

index generic with alias, alias generic

πŸ•— Version & Regression Information

  • This changed between versions 4.1.5 and 4.2.2
    • Still broken in 4.2.4 and nightly

⏯ Playground Link

Playground link

πŸ’» Code

type ValOf<T> = T[keyof T];

const base = {
  color: "green",
} as const;

function getFromPropsNotShared<MoreProps extends {}>(
  moreProps: MoreProps,
  prop: keyof PropsNotShared<typeof base, MoreProps>
): /**
 * Produces error in typescript 4.2.x but succeeds in 4.1.5
 *
 * As background, succeeds in both if you replace the return type assertion:
 *
 * ValOf<PropsNotShared<typeof base, MoreProps>>
 *
 * with:
 *
 * PropsNotShared<typeof base, MoreProps>[ keyof PropsNotShared<typeof base, MoreProps> ]
 *
 * Which is the issue that I am trying to get at because a) the ValOf<...>
 * version succeeded before, and because it seems to me like they should
 * mean the same thing, because the second expression is just like manually
 * resolving ValOf<...> with the actual expression for T.
 */ ValOf<PropsNotShared<typeof base, MoreProps>> {
  // assertion cuz it's just for checking types.
  const notShared = {} as PropsNotShared<typeof base, MoreProps>;
  return notShared[prop];
}

type PropsNotShared<LHS extends {}, RHS extends {}> = Omit<RHS, keyof LHS> &
  Omit<LHS, keyof RHS>;

// example usage
const len = getFromPropsNotShared({ len: 10 as const }, "len");

πŸ™ Actual behavior

Says the line return notShared[prop]; in getFromPropsNotShared() is not assignable to the annotated return type. Works if you replace it with a similar annotation manually resolving ValOf. But it worked in 4.1 and it seems to me like the two different return type expressions should mean the same (see comment).

πŸ™‚ Expected behavior

Would expect the line return notShared[prop]; to succeed with the ValOf<...> return type on getFromPropsNotShared().

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented May 14, 2021

So basically you have a higher-order operation that we happen to know doesn't introduce new values, but TS doesn't really have any way to verify that, so rejects that as a lookup since there's no way to establish the symmetry of those two types. I'm not sure why this ever worked.

Even staring at the code I can't tell what the sample is trying to accomplish. If you have some examples of what valid/invalid calls to getFromPropsNotShared and what the return types are supposed to be, I can probably give you a definition that works without error.

@RyanCavanaugh RyanCavanaugh added the Design Limitation Constraints of the existing architecture prevent this from being fixed label May 14, 2021
@iPherian
Copy link
Author

So basically you have a higher-order operation that we happen to know doesn't introduce new values, but TS doesn't really have any way to verify that, so rejects that as a lookup since there's no way to establish the symmetry of those two types.

@RyanCavanaugh Hmm ValOf? Yes, that makes sense. Thanks!

I'm not sure why this ever worked.

Even staring at the code I can't tell what the sample is trying to accomplish. If you have some examples of what valid/invalid calls to getFromPropsNotShared and what the return types are supposed to be, I can probably give you a definition that works without error.

That would be very helpful!

Here's a longer example which may make more sense:

Description

Basically the Errors class manages an object full of error name keys and whose values are metadata for those errors (error codes, etc). And one is able to add to the common errors or override them in the constructor, and then getError() in a type-safe way.

The similarity is between in the OP type PropsNotShared and in the below type CustomObjectAssign. The expressions are similar enough that they both produce errors when running them through the higher order operation ValOf, specifically when returning from getFromPropsNotShared() in the OP and Errors.getError() below. But I hope the following makes more sense.

⏯ Playground Link

playground

πŸ’» Code

type ValOf<T> = T[keyof T];

type ErrorRecord = Record<string, Error>;

interface Error {
  code: number;
}

/**
 * Any extra errors in the Errors constructor will be added to these, and
 * anything with the same property name will override what's here.
 */
const baseErrors = {
  widgetsNotWidgetyEnough: {
    code: 111,
  },
} as const;

/**
 * Manages a record of combined error names -> codes and other metadata.
 */
export class Errors<ExtraErrors extends ErrorRecord> {
  private _errorRecord: CustomObjectAssign<typeof baseErrors, ExtraErrors>;

  constructor(extraErrors: ExtraErrors) {
    this._errorRecord = customObjectAssign(baseErrors, extraErrors);
  }

  public getError(
    errorName: keyof Errors<ExtraErrors>["_errorRecord"]
  ): ValOf<Errors<ExtraErrors>["_errorRecord"]> {
    /**
     * Gives a type error. (is the main problem).
     */
    return this._errorRecord[errorName];
  }
}

// add some extra errors or override base ones
const errors = new Errors({
  widgetsNotWidgetyEnough: {
    code: 114 as const,
  },
  tooManyDoodads: {
    code: 112 as const,
  },
});

// example usages
const widgetsErr = errors.getError("widgetsNotWidgetyEnough"); // should succeed
const doodadsErr = errors.getError("tooManyDoodads");
const gearsErr = errors.getError("needMoreGears"); // should error because this error name is not in either the common or the extra errors

/**
 * The return type is similar to the built in return type of Object.assign,
 * except that identical properties in the RHS override the LHS instead of
 * producing never. Was made because it's closer to how Object.assign actually
 * works.
 */
function customObjectAssign<Target extends {}, Source extends {}>(
  target: Target,
  source: Source
): CustomObjectAssign<Target, Source> {
  return Object.assign({ ...target }, source) as unknown as CustomObjectAssign<
    Target,
    Source
  >;
}

type CustomObjectAssign<LHS extends {}, RHS extends {}> = Omit<RHS, keyof LHS> &
  {
    [K in keyof LHS]: K extends keyof RHS ? RHS[K] : LHS[K];
  };

@iPherian
Copy link
Author

@RyanCavanaugh made the issue in OP more specific and fleshed out the types a bit more. Felt like it was different enough to open a new one: #44108

@whzx5byb
Copy link

whzx5byb commented May 16, 2021

A minimal repo:

type PropsNotShared<LHS extends {}, RHS extends {}> = 
  & Omit<RHS, keyof LHS>
  & Omit<LHS, keyof RHS>
;

function test<T>() {
  type T1 = PropsNotShared<{x: 1}, T>;
  type T2 = PropsNotShared<{x: 1}, T>;
  type N1 = T1[keyof T1];
  type N2 = T2[keyof T2];
  var x: N1 = {} as any;
  var y: N2 = {} as any;
  x = y; // <- error here.
}

The definition of x and y are COMPLETELY THE SAME but they cannot be assigned to each other?

Edit:
According to my investigation, this is essentially cause by the use of Conditional Type.

function test<T>() {
  type T3 = T & (T extends any ? {} : {});
  type T4 = T & (T extends any ? {} : {});
  type N3 = T3[keyof T3];
  type N4 = T4[keyof T4];
  var x3: N3 = {} as any;
  var x4: N4 = {} as any;

  x3 = x4; // <- error here.
}

@iPherian
Copy link
Author

A minimal repo:

type PropsNotShared<LHS extends {}, RHS extends {}> = 
  & Omit<RHS, keyof LHS>
  & Omit<LHS, keyof RHS>
;

function test<T>() {
  type T1 = PropsNotShared<{x: 1}, T>;
  type T2 = PropsNotShared<{x: 1}, T>;
  type N1 = T1[keyof T1];
  type N2 = T2[keyof T2];
  var x: N1 = {} as any;
  var y: N2 = {} as any;
  x = y; // <- error here.
}

Nice!

@iPherian
Copy link
Author

Edit:
According to my investigation, this is essentially cause by the use of Conditional Type.

function test<T>() {
  type T3 = T & (T extends any ? {} : {});
  type T4 = T & (T extends any ? {} : {});
  type N3 = T3[keyof T3];
  type N4 = T4[keyof T4];
  var x3: N3 = {} as any;
  var x4: N4 = {} as any;

  x3 = x4; // <- error here.
}

hmm, you think so?
Because you can keep the conditional and it succeeds:

playground

function test<T>() {
  type T3 = (T extends any ? {} : {});
  type T4 = (T extends any ? {} : {});
  type N3 = T3[keyof T3];
  type N4 = T4[keyof T4];
  var x3: N3 = {} as any;
  var x4: N4 = {} as any;

  x3 = x4; // <- no error anymore.
}

...but have just an intersection and it fails again:

playground

function test<T extends {}, U extends {}>() {
  type T3 = T & U;
  type T4 = T & U;
  type N3 = T3[keyof T3];
  type N4 = T4[keyof T4];
  var x3: N3 = {} as any;
  var x4: N4 = {} as any;

  x3 = x4; // <- error here
}

And there's an intersection in the OP too, perhaps it's caused by intersections.

@whzx5byb
Copy link

And there's an intersection in the OP too, perhaps it's caused by intersections.

It seems that this is caused by #30769.

We know keyof (A & B) is (keyof A) | (keyof B), and T[M | N] is T[M] | T[N], so T3[keyof T3] is resolved to T3[keyof T] | T3[keyof U] as expected, when it is in a "read position".

However, when T3[keyof T3] appears in the "write position", it uses an intersection instead of a union, so T3[keyof T3] is resolved to T3[keyof T] & T3[keyof U], which leads to the error.

@iPherian
Copy link
Author

@whzx5byb Thanks for tracking that down! It's very informative.

Given that, and the more I think about this, I confess that I may have made a mistake. So that PR is saying that when T[K] is on the target side of a type relationship it's the intersection of the types of the values of T. But you know, that makes sense. After all, if all you know about K is that it is any of the keys of T, and any of these T[K] have incompatible types, then it shouldn't be possible to assign to them, right? So in that case an intersection makes never which can't be assigned to.

like in here:

type PropsNotShared<LHS extends {}, RHS extends {}> = 
  & Omit<RHS, keyof LHS>
  & Omit<LHS, keyof RHS>
;

function test<T>() {
  type T1 = PropsNotShared<{x: 1}, T>;
  type T2 = PropsNotShared<{x: 1}, T>;
  type N1 = T1[keyof T1];
  type N2 = T2[keyof T2];
  var x: N1 = {} as any;
  var y: N2 = {} as any;
  x = y; // <- error here.
}

All typescript knows about x and y is that they are some value of T1 (or T2 doesn't matter). But all of those values are not neccessarily assignable to each other, hence the error.

In fact, i'm kind've swinging the opposite direction now, and think that it should fail more than it does.

Consider this example which should fail but doesn't.

playground

function test<T>() {
  var x: T[keyof T] = {} as any;
  var y: T[keyof T] = {} as any;
  x = y; // <- should error here, because not all values of T are necessarily assignable to each other
}

The nice thing about PropsNotShared was that it was a longish expression which could be deconstructed by typescript, and so if you were assigning an index of it to another index of it, the target type i.e. intersection of all possible values actually looked to be a different expression than the source type i.e. the union of all possible values. But above, no information is known about T[keyof T] other than that T is a type and it's being indexed by one of it's keys, so perhaps the intersection of all possible values doesn't look any different than their union, and the assignment happens.

@iPherian
Copy link
Author

I'd be willing to close this if @whzx5byb doesn't have any more issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Design Limitation Constraints of the existing architecture prevent this from being fixed
Projects
None yet
Development

No branches or pull requests

3 participants