From fa4099c6438911be3038550facdaa7f384ac92b1 Mon Sep 17 00:00:00 2001 From: Max Isom Date: Sat, 23 Mar 2024 23:55:12 -0700 Subject: [PATCH] Add `DistributedPick` type (#841) Co-authored-by: Sindre Sorhus --- index.d.ts | 1 + readme.md | 1 + source/distributed-pick.d.ts | 85 ++++++++++++++++++++++++++++++++++++ test-d/distributed-pick.ts | 77 ++++++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 source/distributed-pick.d.ts create mode 100644 test-d/distributed-pick.ts diff --git a/index.d.ts b/index.d.ts index 2976e3697..92f33bd91 100644 --- a/index.d.ts +++ b/index.d.ts @@ -7,6 +7,7 @@ export * from './source/observable-like'; // Utilities export type {KeysOfUnion} from './source/keys-of-union'; export type {DistributedOmit} from './source/distributed-omit'; +export type {DistributedPick} from './source/distributed-pick'; export type {EmptyObject, IsEmptyObject} from './source/empty-object'; export type {NonEmptyObject} from './source/non-empty-object'; export type {UnknownRecord} from './source/unknown-record'; diff --git a/readme.md b/readme.md index 569ecfbed..bcac9527a 100644 --- a/readme.md +++ b/readme.md @@ -183,6 +183,7 @@ Click the type names for complete docs. - [`Paths`](source/paths.d.ts) - Generate a union of all possible paths to properties in the given object. - [`SharedUnionFieldsDeep`](source/shared-union-fields-deep.d.ts) - Create a type with shared fields from a union of object types, deeply traversing nested structures. - [`DistributedOmit`](source/distributed-omit.d.ts) - Omits keys from a type, distributing the operation over a union. +- [`DistributedPick`](source/distributed-pick.d.ts) - Picks keys from a type, distributing the operation over a union. ### Type Guard diff --git a/source/distributed-pick.d.ts b/source/distributed-pick.d.ts new file mode 100644 index 000000000..3b10058f6 --- /dev/null +++ b/source/distributed-pick.d.ts @@ -0,0 +1,85 @@ +import type {KeysOfUnion} from './keys-of-union'; + +/** +Pick keys from a type, distributing the operation over a union. + +TypeScript's `Pick` doesn't distribute over unions, leading to the erasure of unique properties from union members when picking keys. This creates a type that only retains properties common to all union members, making it impossible to access member-specific properties after the Pick. Essentially, using `Pick` on a union type merges the types into a less specific one, hindering type narrowing and property access based on discriminants. This type solves that. + +Example: + +``` +type A = { + discriminant: 'A'; + foo: { + bar: string; + }; +}; + +type B = { + discriminant: 'B'; + foo: { + baz: string; + }; +}; + +type Union = A | B; + +type PickedUnion = Pick; +//=> {discriminant: 'A' | 'B', foo: {bar: string} | {baz: string}} + +const pickedUnion: PickedUnion = createPickedUnion(); + +if (pickedUnion.discriminant === 'A') { + // We would like to narrow `pickedUnion`'s type + // to `A` here, but we can't because `Pick` + // doesn't distribute over unions. + + pickedUnion.foo.bar; + //=> Error: Property 'bar' does not exist on type '{bar: string} | {baz: string}'. +} +``` + +@example +``` +type A = { + discriminant: 'A'; + foo: { + bar: string; + }; + extraneous: boolean; +}; + +type B = { + discriminant: 'B'; + foo: { + baz: string; + }; + extraneous: boolean; +}; + +// Notice that `foo.bar` exists in `A` but not in `B`. + +type Union = A | B; + +type PickedUnion = DistributedPick; + +const pickedUnion: PickedUnion = createPickedUnion(); + +if (pickedUnion.discriminant === 'A') { + pickedUnion.foo.bar; + //=> OK + + pickedUnion.extraneous; + //=> Error: Property `extraneous` does not exist on type `Pick`. + + pickedUnion.foo.baz; + //=> Error: `bar` is not a property of `{discriminant: 'A'; a: string}`. +} +``` + +@category Object +*/ +export type DistributedPick> = + ObjectType extends unknown + ? Pick> + : never; diff --git a/test-d/distributed-pick.ts b/test-d/distributed-pick.ts new file mode 100644 index 000000000..5fc5b01e2 --- /dev/null +++ b/test-d/distributed-pick.ts @@ -0,0 +1,77 @@ +import {expectType, expectError} from 'tsd'; +import type {DistributedPick} from '../index'; + +// When passing a non-union type, and +// picking keys that are present in the type. +// It behaves exactly like `Pick`. + +type Example1 = { + a: number; + b: string; +}; + +type Actual1 = DistributedPick; +type Actual2 = DistributedPick; +type Actual3 = DistributedPick; + +type Expected1 = Pick; +type Expected2 = Pick; +type Expected3 = Pick; + +declare const expected1: Expected1; +declare const expected2: Expected2; +declare const expected3: Expected3; + +expectType(expected1); +expectType(expected2); +expectType(expected3); + +// When passing a non-union type, and +// picking keys that are NOT present in the type. +// It behaves exactly like `Pick`, by not letting you +// pick keys that are not present in the type. + +type Example2 = { + a: number; + b: string; +}; + +expectError(() => { + type Actual4 = DistributedPick; +}); + +// When passing a union type, and +// picking keys that are present in some union members. +// It lets you pick keys that are present in some union members, +// and distributes over the union. + +type A = { + discriminant: 'A'; + foo: string; + a: number; +}; + +type B = { + discriminant: 'B'; + foo: string; + bar: string; + b: string; +}; + +type C = { + discriminant: 'C'; + bar: string; + c: boolean; +}; + +type Union = A | B | C; + +type PickedUnion = DistributedPick; + +declare const pickedUnion: PickedUnion; + +if (pickedUnion.discriminant === 'A') { + expectType<{discriminant: 'A'; a: number}>(pickedUnion); + expectError(pickedUnion.foo); + expectError(pickedUnion.bar); +}