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

Merge trivially mergeable intersection types for identity comparison #60726

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

MichaelMitchell-at
Copy link

@MichaelMitchell-at MichaelMitchell-at commented Dec 10, 2024

Please verify that:

Does not explicitly fix any particular issues I can find, but addresses use cases in the comments of #27024


TL;DR: this PR makes {a: 1} & {b: 2} compare "equal" to {a: 1; b: 2}.

Use case:

Type testing libraries want to be able to compare types that are intuitively equal. Examples of such libraries include expect-type and type-plus. These try to leverage type comparisons that use the underlying isTypeIdenticalTo function in checker.ts. isTypeIdenticalTo has stronger guarantees than mutual assignability, like ensuring {a?: 1} != {} and {readonly a: 1} != {a: 1}.

Lack of these guarantees leads to deficiences like

import {z} from 'zod';
const mismatch1: z.ZodType<{a?: number}> = z.object({});  // type error desirable
const mismatch2: z.ZodType<{a: number}> = z.object({a: z.number()}).readonly();  // type error desirable

It's increasingly important to be able to trust explicit type annotations under isolated declarations.

Approach:

Unlike #60417, this PR does not achieve equality by changing how types are normalized. That's because we don't actually want to change how the type is represented. For example, if we were to normalize {a: 1} & {b: 2} to {a: 1; b: 2} the following code would break:

function example<T extends {} | 1>(t: T) {
    // Ok because {a: 1} relates to {a: number} and {b: 2} relates to {b: number}
    Object.assign(t, {a: 1}, {b: 2}) satisfies {a: number} & {b: number}

    // @ts-expect-error Neither {a: 1} nor {b: 2} relates to {a: number; b: number}
    Object.assign(t, {a: 1}, {b: 2}) satisfies {a: number; b: number}
}

Instead this approach updates the logic of isRelatedTo to directly handle this case.

Caveats

This PR only attempts to make "trivial cases" work. At a high level this includes only intersections of plain object literals. I'll try to explain why other cases are not covered. Also consult the new test cases I added as reference.

Supported - All intersection constituents are plain object literals

{a: 1} & {b: 2} & {c: 3} == {a: 1; b: 2; c: 3}

Supported - Recursive comparisons

{a: {x: 1}} & {a: {y: 2}} == {a: {x: 1; y: 2}}

Unsupported - Any objects have call or construct signatures

{a: 1} & {(): void} != {a: 1; (): void}

This is not supported right now because merging call signatures is more complicated. You would have to ensure that the ordering is preserved since the intersection of call signatures is equivalent to function overloading, where order matters. A subset of simpler call signature cases could be supported, but it doesn't seem worth the complexity for only a half-baked solution.

Unsupported - Any objects have index signatures

{a: 1} & {[key: number]: 2} != {a: 1; [key: number]: 2}

The main reason to not support this now is because recursive merging can be more complicated, e.g.
You can write {[key: number]: {x: 1}} & {[key: number]: {z: 1}} but writing {[key: number]: {x: 1}; [key: number]: {z: 1}} isn't allowed!

Unsupported - Intersection includes non-plain objects, notably generic type variables

T & {a: 1} & {b: 2} != T & {a: 1} & {b: 2}

The main reason to not support this is because the implementation just becomes complicated and causes more types to be instantiated. Also consider the type {a: 1} & T & {b: 2}. At first glance, you might think that it's ok to make this equal to T & {a: 1} & {b: 2} and hence equal to T & {a: 1; b: 2}, but it's not actually safe to reorder constituents of intersections. As mentioned above, changing the order of call signatures changes the semantics of the type:

function example<T extends {a: () => void}>(t: T, x: {a: () => 1}, y: {a: () => 2}) {
   return Object.assign(x, t, y);
   // => {a: () => 1} & T & {a: () => 2}
   // => {a: () => 1} & T & {a: () => void} & {a: () => 2}
}

Like other cases, we could try to handle a simpler subset of cases, but it doesn't seem worth it.

Testing

This patch has been tested in Airtable's codebase and does not introduce any visible regressions. I added several test cases in this PR.

Note

This PR does not automatically make libraries like expect-type work. For example, this will still not work:

import {expectTypeOf} from 'expect-type'
expectTypeOf<{a: 1} & {b: 2}>().toEqualTypeOf<{a: 1; b: 2}>()

Why? Because of the way that expect-type implements its equality check.

export type StrictEqualUsingTSInternalIdenticalToOperator<L, R> =
  (<T>() => T extends (L & T) | T ? true : false) extends <T>() => T extends (R & T) | T ? true : false
    ? IsNever<L> extends IsNever<R>
      ? true
      : false
    : false

Note the (L & T) and (R & T). These intersections contain generic type variables and hence fall under the caveat above. I do not know why expect-type implements type equality this way. I mean I know that it probably copy-pasted the recipe from this thread, but it's unclear why the recipe is defined that way. My proposal for libraries like expect-type to benefit from this PR is to simplify their implementation to:

export type StrictEqualUsingTSInternalIdenticalToOperator<L, R> =
  (<T>() => T extends L ? true : false) extends <T>() => T extends R ? true : false
    ? IsNever<L> extends IsNever<R>
      ? true
      : false
    : false

I don't know of a case that the former definition satisfies that the latter does not, so if anyone knows, feel free to chime in.

@typescript-bot typescript-bot added the For Uncommitted Bug PR for untriaged, rejected, closed or missing bug label Dec 10, 2024
@typescript-bot
Copy link
Collaborator

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

@MichaelMitchell-at MichaelMitchell-at force-pushed the identical_intersection_types branch from f7b81cf to 7c9d5fa Compare December 10, 2024 00:37
@RyanCavanaugh
Copy link
Member

@typescript-bot test it

@typescript-bot
Copy link
Collaborator

typescript-bot commented Dec 12, 2024

Starting jobs; this comment will be updated as builds start and complete.

Command Status Results
test top400 ✅ Started 👀 Results
user test this ✅ Started ✅ Results
run dt ✅ Started 👀 Results
perf test this faster ❌ Error: Error: {"$id":"1","innerException":null,"message":"TF400898: An Internal Error Occurred.","typeName":"Microsoft.VisualStudio.Services.CircuitBreaker.CircuitBreakerExceededConcurrencyException, Microsoft.VisualStudio.Services.Common","typeKey":"CircuitBreakerExceededConcurrencyException","errorCode"

@typescript-bot
Copy link
Collaborator

@RyanCavanaugh Here are the results of running the user tests with tsc comparing main and refs/pull/60726/merge:

Everything looks good!

@typescript-bot
Copy link
Collaborator

Hey @RyanCavanaugh, the results of running the DT tests are ready.

There were interesting changes:

Branch only errors:

Package: hasura
Error:

Error: 
/mnt/vss/_work/1/DefinitelyTyped/types/hasura/hasura-tests.ts
  44:23  error  TypeScript@local compile error: 
Object literal may only specify known properties, and '_eq' does not exist in type 'WhereBoolExp<ScalarJSON<{ foo: string; }>>'                        @definitelytyped/expect
  67:5   error  TypeScript@local compile error: 
Type 'string' is not assignable to type 'OrderBy<ScalarJSON<{ foo: string; }>>'.
  Type 'string' is not assignable to type '{ [key: string]: any; }'   @definitelytyped/expect
  68:5   error  TypeScript@local compile error: 
Type 'string' is not assignable to type 'OrderBy<ScalarJSONB<{ foo: string; }>>'.
  Type 'string' is not assignable to type '{ [key: string]: any; }'  @definitelytyped/expect

✖ 3 problems (3 errors, 0 warnings)

    at combineErrorsAndWarnings (/mnt/vss/_work/1/DefinitelyTyped/node_modules/.pnpm/@definitelytyped+dtslint@0.2.28_typescript@5.8.0-dev.20241212/node_modules/@definitelytyped/dtslint/dist/index.js:194:28)
    at runTests (/mnt/vss/_work/1/DefinitelyTyped/node_modules/.pnpm/@definitelytyped+dtslint@0.2.28_typescript@5.8.0-dev.20241212/node_modules/@definitelytyped/dtslint/dist/index.js:186:20)

You can check the log here.

@MichaelMitchell-at
Copy link
Author

Hey @RyanCavanaugh, the results of running the DT tests are ready.

There were interesting changes:

Branch only errors:
Package: hasura Error:

Error: 
/mnt/vss/_work/1/DefinitelyTyped/types/hasura/hasura-tests.ts
  44:23  error  TypeScript@local compile error: 
Object literal may only specify known properties, and '_eq' does not exist in type 'WhereBoolExp<ScalarJSON<{ foo: string; }>>'                        @definitelytyped/expect
  67:5   error  TypeScript@local compile error: 
Type 'string' is not assignable to type 'OrderBy<ScalarJSON<{ foo: string; }>>'.
  Type 'string' is not assignable to type '{ [key: string]: any; }'   @definitelytyped/expect
  68:5   error  TypeScript@local compile error: 
Type 'string' is not assignable to type 'OrderBy<ScalarJSONB<{ foo: string; }>>'.
  Type 'string' is not assignable to type '{ [key: string]: any; }'  @definitelytyped/expect

✖ 3 problems (3 errors, 0 warnings)

    at combineErrorsAndWarnings (/mnt/vss/_work/1/DefinitelyTyped/node_modules/.pnpm/@definitelytyped+dtslint@0.2.28_typescript@5.8.0-dev.20241212/node_modules/@definitelytyped/dtslint/dist/index.js:194:28)
    at runTests (/mnt/vss/_work/1/DefinitelyTyped/node_modules/.pnpm/@definitelytyped+dtslint@0.2.28_typescript@5.8.0-dev.20241212/node_modules/@definitelytyped/dtslint/dist/index.js:186:20)

You can check the log here.

Will take a look at this this week

@typescript-bot
Copy link
Collaborator

@RyanCavanaugh Here are the results of running the top 400 repos with tsc comparing main and refs/pull/60726/merge:

Something interesting changed - please have a look.

Details

colinhacks/zod

1 of 5 projects failed to build with the old tsc and were ignored

tsconfig.json

@MichaelMitchell-at
Copy link
Author

I've sent PRs for @types/hasura and zod with forwards-compatible changes.

@RyanCavanaugh
Copy link
Member

Can't promise we'll get to this before the holidays, but we'll definitely discuss

@som-sm
Copy link

som-sm commented Dec 28, 2024

I don't know of a case that the former definition satisfies that the latter does not, so if anyone knows, feel free to chime in.

@MichaelMitchell-at Looks like it's because of the two cases mentioned here.

@mrazauskas
Copy link

Very interesting! Is it be possible to pack this patch for testing, please?

I am interested to try it with TSTyche which is using .isTypeRelatedTo() to compare types.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
For Uncommitted Bug PR for untriaged, rejected, closed or missing bug
Projects
Status: Not started
Development

Successfully merging this pull request may close these issues.

5 participants