mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
46d2cb7ff0
Previously, when you passed an object typed like
Record<'a' | 'b', number> into the `keyvalue` pipe, TypeScript would
"forget" that the keys could only ever be 'a' or 'b', and just tell
you the key was a plain `string` instead. So code like this used to
fail to compile, even though it's correct:
```ts
const input: Record<'a' | 'b', number> = {a: 1, b: 2};
const result = pipe.transform(input);
const key: 'a' | 'b' = result[0].key; // error: string is not 'a' | 'b'
```
This happened because the pipe has multiple overloaded versions of
transform(), and TypeScript checks them top to bottom, using the
first one that matches. The "number keys" overload was listed first,
and it happened to also match string-keyed objects by accident, so
it "won" before the correct "string keys" overload ever got a
chance to run.
The fix just reorders those two overloads so the string-keys one is
checked first. Nothing about runtime behavior changes — objects with
actual numeric keys (e.g. Record<1 | 2, string>) still correctly
report their keys as plain `string`, matching what Object.keys()
really returns at runtime.
399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google LLC All Rights Reserved.
|
|
*
|
|
* Use of this source code is governed by an MIT-style license that can be
|
|
* found in the LICENSE file at https://angular.dev/license
|
|
*/
|
|
|
|
import {Component, ɵdefaultKeyValueDiffers as defaultKeyValueDiffers} from '@angular/core';
|
|
import {TestBed} from '@angular/core/testing';
|
|
import {KeyValuePipe} from '../../index';
|
|
import {JsonPipe} from '../../public_api';
|
|
import {defaultComparator} from '../../src/pipes/keyvalue_pipe';
|
|
|
|
describe('KeyValuePipe', () => {
|
|
it('should return null when given null', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
expect(pipe.transform(null)).toEqual(null);
|
|
});
|
|
it('should return null when given undefined', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
expect(pipe.transform(undefined)).toEqual(null);
|
|
});
|
|
it('should return null for an unsupported type', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
const fn = () => {};
|
|
expect(pipe.transform(fn as any as null)).toEqual(null);
|
|
});
|
|
describe('object dictionary', () => {
|
|
it('should return empty array of an empty dictionary', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
expect(pipe.transform({})).toEqual([]);
|
|
});
|
|
it('should transform a basic dictionary', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
expect(pipe.transform({1: 2})).toEqual([{key: '1', value: 2}]);
|
|
});
|
|
it('should order by alpha', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
expect(pipe.transform({'b': 1, 'a': 1})).toEqual([
|
|
{key: 'a', value: 1},
|
|
{key: 'b', value: 1},
|
|
]);
|
|
});
|
|
it('should order by numerical', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
expect(pipe.transform({2: 1, 1: 1})).toEqual([
|
|
{key: '1', value: 1},
|
|
{key: '2', value: 1},
|
|
]);
|
|
});
|
|
it('should order by numerical and alpha', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
const input = {2: 1, 1: 1, 'b': 1, 0: 1, 3: 1, 'a': 1};
|
|
expect(pipe.transform(input)).toEqual([
|
|
{key: '0', value: 1},
|
|
{key: '1', value: 1},
|
|
{key: '2', value: 1},
|
|
{key: '3', value: 1},
|
|
{key: 'a', value: 1},
|
|
{key: 'b', value: 1},
|
|
]);
|
|
});
|
|
it('should not order by alpha when compareFn is null', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
expect(pipe.transform({'b': 1, 'a': 1}, null)).toEqual([
|
|
{key: 'b', value: 1},
|
|
{key: 'a', value: 1},
|
|
]);
|
|
});
|
|
it('should reorder when compareFn changes', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
const input = {'b': 1, 'a': 2};
|
|
pipe.transform<string, number>(input);
|
|
expect(pipe.transform<string, number>(input, (a, b) => a.value - b.value)).toEqual([
|
|
{key: 'b', value: 1},
|
|
{key: 'a', value: 2},
|
|
]);
|
|
});
|
|
it('should return the same ref if nothing changes', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
const transform1 = pipe.transform({1: 2});
|
|
const transform2 = pipe.transform({1: 2});
|
|
expect(transform1 === transform2).toEqual(true);
|
|
});
|
|
it('should return a new ref if something changes', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
const transform1 = pipe.transform({1: 2});
|
|
const transform2 = pipe.transform({1: 3});
|
|
expect(transform1 !== transform2).toEqual(true);
|
|
});
|
|
it('should accept a type union of an object with string keys and null', () => {
|
|
let value!: {[key: string]: string} | null;
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
expect(pipe.transform(value)).toEqual(null);
|
|
});
|
|
it('should accept a type union of an object with number keys and null', () => {
|
|
let value!: {[key: number]: string} | null;
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
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;
|
|
two: number;
|
|
three: string;
|
|
four: string;
|
|
}
|
|
const myData: Partial<MyInterface> = {
|
|
one: 'One',
|
|
two: 2,
|
|
three: undefined,
|
|
};
|
|
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
expect(pipe.transform(myData)?.length).toEqual(3);
|
|
|
|
const differ = (a: string | number | undefined, b: string | number | undefined): number => {
|
|
return 1;
|
|
};
|
|
expect(pipe.transform(myData, differ)?.length).toEqual(3);
|
|
});
|
|
|
|
it('should accept an nullable object with optional keys (null)', () => {
|
|
interface MyInterface {
|
|
one?: string;
|
|
two?: string;
|
|
three?: string;
|
|
}
|
|
|
|
let value!: MyInterface | null;
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
expect(pipe.transform(value)).toEqual(null);
|
|
});
|
|
|
|
it('should accept an nullable object with optional keys (non-null)', () => {
|
|
interface MyInterface {
|
|
one?: string;
|
|
two?: string;
|
|
three?: string;
|
|
}
|
|
|
|
const value: MyInterface | null = {};
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
expect(pipe.transform(value)?.length).toEqual(0);
|
|
|
|
// we use the random condition to make sure the typing includes null (else TS's inference is too smart and strips null)
|
|
const value2: MyInterface | null = Math.random() <= 1 ? {one: '1', three: '3'} : null;
|
|
const kv = pipe.transform(value2);
|
|
expect(kv?.length).toEqual(2);
|
|
expect(kv).toContain({key: 'one', value: '1'});
|
|
expect(kv).toContain({key: 'three', value: '3'});
|
|
});
|
|
});
|
|
|
|
describe('Map', () => {
|
|
it('should return an empty array for an empty Map', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
expect(pipe.transform(new Map())).toEqual([]);
|
|
});
|
|
it('should transform a basic Map', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
expect(pipe.transform(new Map([[1, 2]]))).toEqual([{key: 1, value: 2}]);
|
|
});
|
|
it('should order by alpha', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
expect(
|
|
pipe.transform(
|
|
new Map([
|
|
['b', 1],
|
|
['a', 1],
|
|
]),
|
|
),
|
|
).toEqual([
|
|
{key: 'a', value: 1},
|
|
{key: 'b', value: 1},
|
|
]);
|
|
});
|
|
it('should order by numerical', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
expect(
|
|
pipe.transform(
|
|
new Map([
|
|
[2, 1],
|
|
[1, 1],
|
|
]),
|
|
),
|
|
).toEqual([
|
|
{key: 1, value: 1},
|
|
{key: 2, value: 1},
|
|
]);
|
|
});
|
|
it('should order by numerical and alpha', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
const input = [
|
|
[2, 1],
|
|
[1, 1],
|
|
['b', 1],
|
|
[0, 1],
|
|
[3, 1],
|
|
['a', 1],
|
|
] as Array<[number | string, number]>;
|
|
expect(pipe.transform(new Map(input))).toEqual([
|
|
{key: 0, value: 1},
|
|
{key: 1, value: 1},
|
|
{key: 2, value: 1},
|
|
{key: 3, value: 1},
|
|
{key: 'a', value: 1},
|
|
{key: 'b', value: 1},
|
|
]);
|
|
});
|
|
it('should order by complex types with compareFn', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
const input = new Map([
|
|
[{id: 1}, 1],
|
|
[{id: 0}, 1],
|
|
]);
|
|
expect(
|
|
pipe.transform<{id: number}, number>(input, (a, b) => (a.key.id > b.key.id ? 1 : -1)),
|
|
).toEqual([
|
|
{key: {id: 0}, value: 1},
|
|
{key: {id: 1}, value: 1},
|
|
]);
|
|
});
|
|
it('should not order by alpha when compareFn is null', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
expect(
|
|
pipe.transform(
|
|
new Map([
|
|
['b', 1],
|
|
['a', 1],
|
|
]),
|
|
null,
|
|
),
|
|
).toEqual([
|
|
{key: 'b', value: 1},
|
|
{key: 'a', value: 1},
|
|
]);
|
|
});
|
|
it('should reorder when compareFn changes', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
const input = new Map([
|
|
['b', 1],
|
|
['a', 2],
|
|
]);
|
|
pipe.transform<string, number>(input);
|
|
expect(pipe.transform<string, number>(input, (a, b) => a.value - b.value)).toEqual([
|
|
{key: 'b', value: 1},
|
|
{key: 'a', value: 2},
|
|
]);
|
|
});
|
|
it('should return the same ref if nothing changes', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
const transform1 = pipe.transform(new Map([[1, 2]]));
|
|
const transform2 = pipe.transform(new Map([[1, 2]]));
|
|
expect(transform1 === transform2).toEqual(true);
|
|
});
|
|
it('should return a new ref if something changes', () => {
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
const transform1 = pipe.transform(new Map([[1, 2]]));
|
|
const transform2 = pipe.transform(new Map([[1, 3]]));
|
|
expect(transform1 !== transform2).toEqual(true);
|
|
});
|
|
it('should accept a type union of a Map and null', () => {
|
|
let value!: Map<number, number> | null;
|
|
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
|
|
expect(pipe.transform(value)).toEqual(null);
|
|
});
|
|
});
|
|
|
|
it('should be available as a standalone pipe', async () => {
|
|
@Component({
|
|
selector: 'test-component',
|
|
imports: [KeyValuePipe, JsonPipe],
|
|
template: '{{ value | keyvalue | json }}',
|
|
})
|
|
class TestComponent {
|
|
value = {'b': 1, 'a': 2};
|
|
}
|
|
|
|
const fixture = TestBed.createComponent(TestComponent);
|
|
await fixture.whenStable();
|
|
|
|
const content = fixture.nativeElement.textContent;
|
|
expect(content.replace(/\s/g, '')).toBe('[{"key":"a","value":2},{"key":"b","value":1}]');
|
|
});
|
|
});
|
|
|
|
describe('defaultComparator', () => {
|
|
it('should remain the same order when keys are equal', () => {
|
|
const key = 1;
|
|
const values = [
|
|
{key, value: 2},
|
|
{key, value: 1},
|
|
];
|
|
expect(values.sort(defaultComparator)).toEqual(values);
|
|
});
|
|
it('should sort undefined keys to the end', () => {
|
|
const values = [
|
|
{key: 3, value: 1},
|
|
{key: undefined, value: 3},
|
|
{key: 1, value: 2},
|
|
];
|
|
expect(values.sort(defaultComparator)).toEqual([
|
|
{key: 1, value: 2},
|
|
{key: 3, value: 1},
|
|
{key: undefined, value: 3},
|
|
]);
|
|
});
|
|
it('should sort null keys to the end', () => {
|
|
const values = [
|
|
{key: 3, value: 1},
|
|
{key: null, value: 3},
|
|
{key: 1, value: 2},
|
|
];
|
|
expect(values.sort(defaultComparator)).toEqual([
|
|
{key: 1, value: 2},
|
|
{key: 3, value: 1},
|
|
{key: null, value: 3},
|
|
]);
|
|
});
|
|
it('should sort strings in alpha ascending', () => {
|
|
const values = [
|
|
{key: 'b', value: 1},
|
|
{key: 'a', value: 3},
|
|
];
|
|
expect(values.sort(defaultComparator)).toEqual([
|
|
{key: 'a', value: 3},
|
|
{key: 'b', value: 1},
|
|
]);
|
|
});
|
|
it('should sort numbers in numerical ascending', () => {
|
|
const values = [
|
|
{key: 2, value: 1},
|
|
{key: 1, value: 3},
|
|
];
|
|
expect(values.sort(defaultComparator)).toEqual([
|
|
{key: 1, value: 3},
|
|
{key: 2, value: 1},
|
|
]);
|
|
});
|
|
it('should sort boolean in false (0) -> true (1)', () => {
|
|
const values = [
|
|
{key: true, value: 3},
|
|
{key: false, value: 1},
|
|
];
|
|
expect(values.sort(defaultComparator)).toEqual([
|
|
{key: false, value: 1},
|
|
{key: true, value: 3},
|
|
]);
|
|
});
|
|
it('should sort numbers as strings in numerical ascending', () => {
|
|
// We need to cast the values array to "any[]" because the object keys
|
|
// have no type overlap and the "Array.sort" expects all keys to have the
|
|
// same type when passed to the sort comparator.
|
|
const values = [
|
|
{key: '2', value: 1},
|
|
{key: 1, value: 3},
|
|
] as any[];
|
|
expect(values.sort(defaultComparator)).toEqual([
|
|
{key: 1, value: 3},
|
|
{key: '2', value: 1},
|
|
]);
|
|
});
|
|
});
|