Skip to content

A union of identical types fails identicality #223

@rickosborne

Description

@rickosborne

tl;dr

When identicality checking the received type R and expected type E, the check will fail if one (say, R) is a union, say R = R1 | R2, even if E is identical to all parts of the union, E === R1 and E === R2, and the parts of the union are also identical, R1 === R2.

That is, it seems like an additional check in identicality.ts could recurse into intersection types and return true if all parts of each union are identical.

Setup

This is the type I was working on:

/**
 * Remove the "wildcard" parts of an object, leaving only the concrete,
 * specified parts.  That is, it removes any keys which could be any
 * string, any number, or any symbol.
 */
export type NoRecord<T extends object> = object extends T ? Record<never, unknown> : {
	[K in keyof T as K extends string ? string extends K ? never : K : K extends symbol ? symbol extends K ? never : K : K extends number ? number extends K ? never : K : never]: T[K];
};

I then have some test fixture types which include the record behavior I want to filter out:

type AnyRecord = {
	readonly id: number;
	flag?: boolean;
} & Record<string | number | symbol, string>;

type StringRecord = {
	readonly id: number;
	flag?: boolean;
} & Record<string, string>;

type NumberRecord = {
	readonly id: number;
	flag?: boolean;
	[key: number]: string;
}

type SymbolRecord = {
	readonly id: number;
	flag?: boolean;
	[key: symbol]: string;
}

When run through NoRecord, all of these should reduce to:

type Specifics = {
	readonly id: number;
	flag?: boolean;
};

I also have a helper function which does nothing but hold type signatures:

const f = <T>(): T => undefined as T;

The parts which work fine:

expectType<Specifics>(f<NoRecord<AnyRecord>>());  // ✅
expectType<Specifics>(f<NoRecord<StringRecord>>());  // ✅
expectType<Specifics>(f<NoRecord<NumberRecord>>());  // ✅
expectType<Specifics>(f<NoRecord<SymbolRecord>>());  // ✅

But this breaks:

expectType<Specifics>(f<NoRecord<NumberRecord | StringRecord>>());  // ❌

If you relax the test to assignability, it will pass just fine:

expectAssignable<Specifics>(f<NoRecord<SymbolRecord | StringRecord>>());  // ✅
expectAssignable<NoRecord<SymbolRecord | StringRecord>>(f<Specifics>());  // ✅
expectAssignable<Specifics>(f<NoRecord<SymbolRecord | NumberRecord>>());  // ✅
expectAssignable<Specifics>(f<NoRecord<NumberRecord | StringRecord>>());  // ✅

For the failure of the strict check, the error you get is:

  ✖  47:0  Parameter type Specifics is not identical to argument type { readonly id: number; flag?: boolean | undefined; } | { readonly id: number; flag?: boolean | undefined; }.

- { readonly id: number; flag?: boolean | undefined; }
+ { readonly id: number; flag?: boolean | undefined; } | { readonly id: number; flag?: boolean | undefined; }  

This is, at first, mystifying. "Hey. Why is that still a union if the two types are the same? Why doesn't it collapse?" I assume there's a perfectly good reason for this at the tsc level ... but I am not smart enough to understand it.

Fire up a debugger on the failing assertion, and you end up in identical.ts:53. It sees the received type as a union of two subtypes. And if I exhaustively compare them:

checker.isTypeIdenticalTo(expectedType, receivedType.types[0])  // E === R1? true
checker.isTypeIdenticalTo(expectedType, receivedType.types[1])  // E === R2? true
checker.isTypeIdenticalTo(receivedType.types[0], receivedType.types[1])  // R1 === R2? true

All the subtypes are considered identical, but the two top-level types are not.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions