From bc4957763a49212339a6c5bbd13f3115d658fda7 Mon Sep 17 00:00:00 2001 From: Henrique Inonhe Date: Mon, 18 Mar 2024 11:31:08 -0300 Subject: [PATCH] Add `DistributedOmit` type (#820) Co-authored-by: Sindre Sorhus --- index.d.ts | 1 + readme.md | 1 + source/distributed-omit.d.ts | 89 ++++++++++++++++++++++++++++++++++++ test-d/distributed-omit.ts | 77 +++++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+) create mode 100644 source/distributed-omit.d.ts create mode 100644 test-d/distributed-omit.ts diff --git a/index.d.ts b/index.d.ts index aa1a4260c..2976e3697 100644 --- a/index.d.ts +++ b/index.d.ts @@ -6,6 +6,7 @@ export * from './source/observable-like'; // Utilities export type {KeysOfUnion} from './source/keys-of-union'; +export type {DistributedOmit} from './source/distributed-omit'; 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 19641a963..569ecfbed 100644 --- a/readme.md +++ b/readme.md @@ -182,6 +182,7 @@ Click the type names for complete docs. - [`SetFieldType`](source/set-field-type.d.ts) - Create a type that changes the type of the given keys. - [`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. ### Type Guard diff --git a/source/distributed-omit.d.ts b/source/distributed-omit.d.ts new file mode 100644 index 000000000..5a61404b7 --- /dev/null +++ b/source/distributed-omit.d.ts @@ -0,0 +1,89 @@ +import type {KeysOfUnion} from './keys-of-union'; + +/** +Omits keys from a type, distributing the operation over a union. + +TypeScript's `Omit` doesn't distribute over unions, leading to the erasure of unique properties from union members when omitting keys. This creates a type that only retains properties common to all union members, making it impossible to access member-specific properties after the Omit. Essentially, using `Omit` 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: string; + a: number; +}; + +type B = { + discriminant: 'B'; + foo: string; + b: string; +}; + +type Union = A | B; + +type OmittedUnion = Omit; +//=> {discriminant: 'A' | 'B'} + +const omittedUnion: OmittedUnion = createOmittedUnion(); + +if (omittedUnion.discriminant === 'A') { + // We would like to narrow `omittedUnion`'s type + // to `A` here, but we can't because `Omit` + // doesn't distribute over unions. + + omittedUnion.a; + //=> Error: `a` is not a property of `{discriminant: 'A' | 'B'}` +} +``` + +While `Except` solves this problem, it restricts the keys you can omit to the ones that are present in **ALL** union members, where `DistributedOmit` allows you to omit keys that are present in **ANY** union member. + +@example +``` +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; +}; + +// Notice that `foo` exists in `A` and `B`, but not in `C`, and +// `bar` exists in `B` and `C`, but not in `A`. + +type Union = A | B | C; + +type OmittedUnion = DistributedOmit; + +const omittedUnion: OmittedUnion = createOmittedUnion(); + +if (omittedUnion.discriminant === 'A') { + omittedUnion.a; + //=> OK + + omittedUnion.foo; + //=> Error: `foo` is not a property of `{discriminant: 'A'; a: string}` + + omittedUnion.bar; + //=> Error: `bar` is not a property of `{discriminant: 'A'; a: string}` +} +``` + +@category Object +*/ +export type DistributedOmit> = + ObjectType extends unknown + ? Omit + : never; diff --git a/test-d/distributed-omit.ts b/test-d/distributed-omit.ts new file mode 100644 index 000000000..bbe35a366 --- /dev/null +++ b/test-d/distributed-omit.ts @@ -0,0 +1,77 @@ +import {expectType, expectError} from 'tsd'; +import type {DistributedOmit, Except} from '../index'; + +// When passing a non-union type, and +// omitting keys that are present in the type. +// It behaves exactly like `Except`. + +type Example1 = { + a: number; + b: string; +}; + +type Actual1 = DistributedOmit; +type Actual2 = DistributedOmit; +type Actual3 = DistributedOmit; + +type Expected1 = Except; +type Expected2 = Except; +type Expected3 = Except; + +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 +// omitting keys that are NOT present in the type. +// It behaves exactly like `Except`, by not letting you +// omit keys that are not present in the type. + +type Example2 = { + a: number; + b: string; +}; + +expectError(() => { + type Actual4 = DistributedOmit; +}); + +// When passing a union type, and +// omitting keys that are present in some union members. +// It lets you omit 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 OmittedUnion = DistributedOmit; + +declare const omittedUnion: OmittedUnion; + +if (omittedUnion.discriminant === 'A') { + expectType<{discriminant: 'A'; a: number}>(omittedUnion); + expectError(omittedUnion.foo); + expectError(omittedUnion.bar); +}