Skip to content

Add ExtractStrict type #1119

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
3 changes: 3 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,6 @@ export type {LastArrayElement} from './source/last-array-element';
export type {GlobalThis} from './source/global-this';
export type {PackageJson} from './source/package-json';
export type {TsConfigJson} from './source/tsconfig-json';

// Improved Built-in
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per this comment: #291 (comment), I have added this new section/category.

export type {ExtractStrict} from './source/extract-strict';
4 changes: 4 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,10 @@ type ShouldBeNever = IfAny<'not any', 'not never', 'never'>;
- [`PackageJson`](source/package-json.d.ts) - Type for [npm's `package.json` file](https://docs.npmjs.com/creating-a-package-json-file). It also includes support for [TypeScript Declaration Files](https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html).
- [`TsConfigJson`](source/tsconfig-json.d.ts) - Type for [TypeScript's `tsconfig.json` file](https://www.typescriptlang.org/docs/handbook/tsconfig-json.html).

### Improved Built-in

- [`ExtractStrict`](source/extract-strict.d.ts) - Like `Extract<Type, Union>`, but all members of `Union` are restricted to be subsets of some member of `Type`.

## Declined types

*If we decline a type addition, we will make sure to document the better solution here.*
Expand Down
56 changes: 56 additions & 0 deletions source/extract-strict.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type {Exact} from './exact';

/**
Extract members of a union type `Type` based on the
fields in the given union type `Union`, where each
union member of `Union` is only allowed to be a subset
of some union member of `Type`.
Comment on lines +5 to +7
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where each
union member of Union is only allowed to be a subset
of some union member of Type.

This is not true; consider the following example:

type T = ExtractStrict<{foo: 1; bar: 1} | {baz: 1}, {foo: 1}>;

The above instantiation of Extract is valid, but {foo: 1} is neither a subset of {foo: 1; bar: 1} nor a subset of {baz: 1}. It is infact a superset of {foo: 1; bar: 1}.

Copy link
Collaborator

@som-sm som-sm May 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the other hand, for something like:

type T = ExtractStrict<string | number, 'foo'>;
//   ^? type T = never

'foo' is now a subset of string.


If I understand the ask correctly, 'foo' should ideally not be allowed here, because it doesn't do any extraction.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the detailed review!

The above instantiation of Extract is valid, but {foo: 1} is neither a subset of {foo: 1; bar: 1} nor a subset of {baz: 1}. It is infact a superset of {foo: 1; bar: 1}.

You're right here in terms of the type, but I'm open to amending the language to whatever you think makes sense to most users. I was thinking a subset of fields, not of the type. Let U be a union member of Union, T be a union member of Type. For a type, U should be a superset of T (because U is more loose than T), but in terms of fields of U, the fields are a subset of the fields of T (basically, a Partial). I can try to make either approach more clear, or am open to whatever language you think makes sense.

If I understand the ask correctly, 'foo' should ideally not be allowed here, because it doesn't do any extraction.

100%. This is a great test case for me to add and calls for a better extends condition for Union

Copy link
Collaborator

@som-sm som-sm May 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, it'll be difficult to clearly explain this using technical terms, so it'd be better if we keep the text simple and explain it with examples. Like:

/**
A stricter version of {@link Extract} that ensures every member of `Union` can successfully extract something from `Type`.

For e.g., `StrictExtract<string | number | boolean, number | bigint>` will error because `bigint` cannot extract anything from `string | number | boolean`.

@example
```
// Valid Examples

type Example1 = ExtractStrict<{status: 'success'; data: string[]} | {status: 'error'; error: string}, {status: 'success'}>;
//=> {status: 'success'; data: string[]}

type Example2 = ExtractStrict<'xs' | 's' | 'm' | 'l' | 'xl', 'xs' | 's'>;
//=> 'xs' | 's'

type Example3 = ExtractStrict<{x: number; y: number} | [number, number], unknown[]>;
//=> [number, number]
```

@example
```
// Invalid Examples

type Example1 = ExtractStrict<'xs' | 's' | 'm' | 'l' | 'xl', 'xl' | 'xxl'>;
//                                                           ~~~~~~~~~~~~
//=> Error: Type "'xl' | 'xxl'" does not satisfy the constraint 'never'.

type Example2 = ExtractStrict<{x: number; y: number} | {x: string; y: string}, unknown[]>;
//                                                                             ~~~~~~~~~
//=> Error: Type 'unknown[]' does not satisfy the constraint 'never'.
```

@category Improved Builtin
*/


Constraint: ∀ U ∈ Union, U ⊆ T, where T ∈ Type
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can remove if this overcomplicates things, but I found this mathematical notation pretty useful


@example
```
type Foo = {
kind: 'foo';
a: string;
b: string;
};

type Bar = {
kind: 'bar';
a: string;
b: number;
c: boolean;
};

type Foobar = Foo | Bar;

type FoobarByA = ExtractStrict<Foobar, {a: string}>;
// => Foobar

type OnlyFooByKind = ExtractStrict<Foobar, {kind: 'foo'}>;
// => Foo

type OnlyFooByB = ExtractStrict<Foobar, {b: string}>;
// => Foo

type OnlyBarByC = ExtractStrict<Foobar, {c: boolean}>;
// => Bar

type InvalidUnionForType = ExtractStrict<Foobar, {d: string}>;
// => Error:
// Types of property 'd' are incompatible.
// Type 'string' is not assignable to type 'never'.
```
@category Improved Builtin
*/
export type ExtractStrict<
Type,
/**
* Only allow keys that are in some union member
* of `Type`. Thus, each union member of `Union`
* is only allowed to be a subset of some union
* member of `Type`.
*/
Union extends Partial<Exact<Type, Union>>,
Copy link
Collaborator

@som-sm som-sm May 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, is ExtractStrict not meant to be used in cases like these:

type Line =
	| {x1: number; y1: number; x2: number; y2: number}
	| [[x1: number, y1: number], [x2: number, y2: number]]
	| [x1: number, y1: number, x2: number, y2: number];
	
type LineArrayNotations = ExtractStrict<Line, unknown[]>; // Errors
//   ^? type LineArrayNotations = [[x1: number, y1: number], [x2: number, y2: number]] | [x1: number, y1: number, x2: number, y2: number];

The above instantiation of ExtractStrict errors even though LineArrayNotations is not never.

Copy link
Collaborator

@som-sm som-sm May 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, the strictness be simplified to just ensure if Union is able to successfully extract something from Type, like:

type ExtractStrict<
  Type,
  Union extends Extract<Type, Union> extends never ? never : unknown,
> = Extract<Type, Union>;

type T1 = ExtractStrict<1 | 2 | 3 | 4, 1 | 2 | 3>;
//   ^? type T1 = 1 | 2 | 3

// @ts-expect-error
type T2 = ExtractStrict<1 | 2 | 3 | 4, "one" | "two">;

// But something like this will also pass
type T3 = ExtractStrict<1 | 2 | 3 | 4, 1 | 2 | "three">;
//   ^? type T3 = 1 | 2

Playground: https://tsplay.dev/wQDG1W

Copy link
Collaborator

@som-sm som-sm May 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe we can ensure that every member of Union is able to do some extraction from Type, like:

type ExtractStrict<
  Type,
  Union extends [Union] extends [
    Union extends unknown ? (Extract<Type, Union> extends never ? never : Union) : never,
  ]
    ? unknown
    : never,
> = Extract<Type, Union>;

type T1 = ExtractStrict<1 | 2 | 3 | 4, 1 | 2 | 3>;
//   ^? type T1 = 1 | 2 | 3

// @ts-expect-error
type T2 = ExtractStrict<1 | 2 | 3 | 4, "one" | "two">;

// @ts-expect-error
type T3 = ExtractStrict<1 | 2 | 3 | 4, 1 | 2 | "three">;

Playground: https://tsplay.dev/m0kXqN

So, both T2 and T3 error now. This also passes all the existing tests.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like your idea of additionally checking Extract<Type, Union> extends never. Let me add that in and see what works best. Thanks!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nigeltroy I'd rather prefer the second approach that I suggested.

> = Extract<Type, Union>;
66 changes: 66 additions & 0 deletions test-d/extract-strict.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {expectType} from 'tsd';
import type {ExtractStrict} from '../source/extract-strict';

// Primitive union tests

type ShirtSize = 'xxxl' | 'xxl' | 'xl' | 'l' | 'm' | 's' | 'xs' | 'xxs';
type LargeShirtSize = 'xxxl' | 'xxl' | 'xl' | 'l';
type SmallShirtSize = 's' | 'xs' | 'xxs';

declare const largeShirtSizes: ExtractStrict<ShirtSize, LargeShirtSize>;
expectType<LargeShirtSize>(largeShirtSizes);

declare const smallShirtSizes: ExtractStrict<ShirtSize, SmallShirtSize>;
expectType<SmallShirtSize>(smallShirtSizes);

// @ts-expect-error
declare const allInvalidShirtSizes: ExtractStrict<ShirtSize, 'skyscraper-large' | 'atom-small'>;
expectType<never>(allInvalidShirtSizes);

// @ts-expect-error
declare const someInvalidShirtSizes: ExtractStrict<ShirtSize, 'm' | 'atom-small'>;
expectType<'m'>(someInvalidShirtSizes); // This is how native `Extract` works with primitives

// Object union tests

type Foo = {
kind: 'foo';
a: string;
b: string;
};

type Bar = {
kind: 'bar';
a: string;
b: number;
c: boolean;
};

type Foobar = Foo | Bar;

declare const foobarByA: ExtractStrict<Foobar, {a: string}>;
expectType<Foobar>(foobarByA);

declare const onlyFooByKind: ExtractStrict<Foobar, {kind: 'foo'}>;
expectType<Foo>(onlyFooByKind);

declare const onlyFooByB: ExtractStrict<Foobar, {b: string}>;
expectType<Foo>(onlyFooByB);

declare const onlyBarByC: ExtractStrict<Foobar, {c: boolean}>;
expectType<Bar>(onlyBarByC);

declare const foobarByUnionBC: ExtractStrict<Foobar, {b: string} | {c: boolean}>;
expectType<Foobar>(foobarByUnionBC);

// @ts-expect-error
declare const invalidLoneField: ExtractStrict<Foobar, {d: string}>;
expectType<never>(invalidLoneField);

// @ts-expect-error
declare const invalidMixedFields: ExtractStrict<Foobar, {kind: 'foo'; d: string}>;
expectType<never>(invalidMixedFields);

// @ts-expect-error
declare const undefinedField: ExtractStrict<Foobar, undefined>;
expectType<never>(undefinedField);