Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions packages/common/src/pipes/keyvalue_pipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,23 @@ export class KeyValuePipe implements PipeTransform {
input: ReadonlyMap<K, V>,
compareFn?: ((a: KeyValue<K, V>, b: KeyValue<K, V>) => number) | null,
): Array<KeyValue<K, V>>;
transform<K extends number, V>(
input: Record<K, V>,
compareFn?: ((a: KeyValue<string, V>, b: KeyValue<string, V>) => number) | null,
): Array<KeyValue<string, V>>;
// TypeScript tries overloads top-to-bottom and stops at the first match. A
// plain object with string-literal keys (e.g. `Record<'a' | 'b', V>`) also
// happens to satisfy the more general `K extends number` overload below once
// inference falls back to its constraint — so if that overload is checked
// first, it wins by accident and silently widens `key` from `'a' | 'b'` to
// plain `string`. Putting the string overload first means it claims
// string-keyed input before the number overload ever gets a chance, while
// genuinely numeric-keyed input (which fails `K extends string`) still falls
// through to the number overload exactly as before.
transform<K extends string, V>(
input: Record<K, V> | ReadonlyMap<K, V>,
compareFn?: ((a: KeyValue<K, V>, b: KeyValue<K, V>) => number) | null,
): Array<KeyValue<K, V>>;
transform<K extends number, V>(
input: Record<K, V>,
compareFn?: ((a: KeyValue<string, V>, b: KeyValue<string, V>) => number) | null,
): Array<KeyValue<string, V>>;
transform(
input: null | undefined,
compareFn?: ((a: KeyValue<unknown, unknown>, b: KeyValue<unknown, unknown>) => number) | null,
Expand All @@ -91,16 +100,16 @@ export class KeyValuePipe implements PipeTransform {
input: ReadonlyMap<K, V> | null | undefined,
compareFn?: ((a: KeyValue<K, V>, b: KeyValue<K, V>) => number) | null,
): Array<KeyValue<K, V>> | null;
transform<K extends number, V>(
input: Record<K, V> | null | undefined,
compareFn?: ((a: KeyValue<string, V>, b: KeyValue<string, V>) => number) | null,
): Array<KeyValue<string, V>> | null;

transform<K extends string, V>(
input: Record<K, V> | ReadonlyMap<K, V> | null | undefined,
compareFn?: ((a: KeyValue<K, V>, b: KeyValue<K, V>) => number) | null,
): Array<KeyValue<K, V>> | null;

transform<K extends number, V>(
input: Record<K, V> | null | undefined,
compareFn?: ((a: KeyValue<string, V>, b: KeyValue<string, V>) => number) | null,
): Array<KeyValue<string, V>> | null;

transform<T>(
input: T,
compareFn?: T extends object ? (a: T[keyof T], b: T[keyof T]) => number : never,
Expand Down
32 changes: 32 additions & 0 deletions packages/common/test/pipes/keyvalue_pipe_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,38 @@ describe('KeyValuePipe', () => {
expect(pipe.transform(value)).toEqual(null);
});

it('should preserve a literal key union instead of widening it to string', () => {
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
const input: Record<'a' | 'b', number> = {a: 1, b: 2};
const result = pipe.transform(input);

// Compile-time check: if `key` had widened back to plain `string`, this
// assignment would fail to compile — that's the actual bug from #43883.
const key: 'a' | 'b' = result[0].key;

expect(key).toBe('a');
expect(result).toEqual([
{key: 'a', value: 1},
{key: 'b', value: 2},
]);
});

it('should still collapse a numerically-keyed object to string keys', () => {
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
const input: Record<1 | 2, string> = {1: 'one', 2: 'two'};
const result = pipe.transform(input);

// Numeric keys become strings at runtime (Object.keys() always returns
// strings), so `key` should stay `string`, not narrow to `1 | 2`.
const key: string = result[0].key;

expect(key).toBe('1');
expect(result).toEqual([
{key: '1', value: 'one'},
{key: '2', value: 'two'},
]);
});

it('should accept an object with optional keys', () => {
interface MyInterface {
one: string;
Expand Down
Loading