From 1a8244109b79211b65441bb9aa32475268db1ec9 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Wed, 24 Sep 2025 20:55:34 +0900 Subject: [PATCH 01/16] pick-deep: Fix the case when the key or element is an union type (#1224) --- source/pick-deep.d.ts | 130 +++++++++++++++++++++++---- test-d/pick-deep.ts | 198 +++++++++++++++++++++++++++--------------- 2 files changed, 243 insertions(+), 85 deletions(-) diff --git a/source/pick-deep.d.ts b/source/pick-deep.d.ts index b5517d782..ed8940e46 100644 --- a/source/pick-deep.d.ts +++ b/source/pick-deep.d.ts @@ -1,7 +1,12 @@ import type {BuildObject, BuildTuple, NonRecursiveType, ObjectValue} from './internal/index.d.ts'; +import type {Get} from './get.d.ts'; import type {IsNever} from './is-never.d.ts'; import type {Paths} from './paths.d.ts'; import type {Simplify} from './simplify.d.ts'; +import type {GreaterThan} from './greater-than.d.ts'; +import type {IsEqual} from './is-equal.d.ts'; +import type {Or} from './or.d.ts'; +import type {KeysOfUnion} from './keys-of-union.d.ts'; import type {UnionToIntersection} from './union-to-intersection.d.ts'; import type {UnknownArray} from './unknown-array.d.ts'; @@ -80,41 +85,47 @@ export type PickDeep> = T extends NonRecursiveType ? never : T extends UnknownArray - ? UnionToIntersection<{ - [P in PathUnion]: InternalPickDeep; - }[PathUnion] - > + ? MergeNarrow<{[P in PathUnion]: InternalPickDeep}[PathUnion]> : T extends object - ? Simplify; - }[PathUnion]>> + ? MergeNarrow>> : never; /** Pick an object/array from the given object/array by one path. */ -type InternalPickDeep = +type InternalPickDeep = T extends NonRecursiveType - ? never + ? T : T extends UnknownArray ? PickDeepArray : T extends object ? Simplify> - : never; + : T; + +type _PickDeepObject = + ObjectValue extends infer ObjectV + ? IsNever extends false + ? BuildObject + : never + : never; /** Pick an object from the given object by one path. */ -type PickDeepObject = +type PickDeepObject = P extends `${infer RecordKeyInPath}.${infer SubPath}` + // `ObjectV` doesn't extends `(UnknownArray | object)` when the union type includes members that don't satisfy that constraint. + // In such cases, `InternalPickDeep` returns the original type itself. + // This allows union types to preserve their structure. ? ObjectValue extends infer ObjectV ? IsNever extends false - ? BuildObject, SubPath>, RecordType> + ? SubPath extends `${infer _MainSubPath}.${infer _NextSubPath}` + ? BuildObject, ObjectV extends (UnknownArray | object) ? ObjectV : never> + : ObjectV extends UnknownArray + ? Simplify, RecordType>> + : Simplify, SubPath>, RecordType>> : never : never - : ObjectValue extends infer ObjectV - ? IsNever extends false - ? BuildObject - : never - : never; + // Case where the path is not concatenated. + : Simplify<_PickDeepObject>; /** Pick an array from the given array by one path. @@ -147,3 +158,88 @@ type PickDeepArray = ? readonly [...BuildTuple, ArrayType[ArrayIndex]] : never : never; + +type _Pick = + {[k in keyof T as `${k extends (string | number) ? k : never}` extends `${Key}` ? k : never]: T[k]}; + +type IsKeyOf = `${k}` extends `${keyof a extends (string | number) ? keyof a : never}` ? true : false; + +type GetOrSelf = (a extends any ? IsEqual> extends true ? Get : a : never); + +type PickOrSelf = (a extends any ? IsEqual> extends true ? _Pick : a : never); + +type LastOfUnion = +UnionToIntersection T : never> extends () => (infer R) + ? R + : never; + +/* +This is a local function that merges multiple objects obtained as a union type when the path in `PickDeep` is a union type. + +Assuming `T` is a union type: + - If a member `t` of `T` is not a collection type, it is returned as is. + - If `t` is an object, each property is narrowed via union, and `MergeNarrow` is applied to any property that is a collection (which would also be a union type). The results are then merged. + - The same logic applies to tuples, but merged via intersection. + +Here is an example that explains why tuples are merged via intersection and objects via union. + +type testMergeNarrow_0 = MergeNarrow + +// string +// | number +// | { a: number; +// d: [0, 1]; +// b: 199 | {readonly c: unknown;}} +// | [0, 1, [2, 3, unknown], {x: unknown; y: 2}] +*/ +type MergeNarrow = + LastOfUnion extends infer L + ? IsNever extends false + ? L extends UnknownArray + ? MergeNarrow, MergeNarrowTuple, M> + : L extends object + ? MergeNarrow, R, MergeNarrowObject> + : L | MergeNarrow, R, M> + : IsEqual<[R, M], [[], {}]> extends true + ? never + : R | M + : never; + +type _MergeNarrowTuple = + A extends readonly [infer HeadA, ...infer RestA] + ? B extends readonly [infer HeadB, ...infer RestB] + ? [HeadA, HeadB] extends infer M extends [UnknownArray, UnknownArray] + ? [MergeNarrowTuple, ..._MergeNarrowTuple] + : [HeadA, HeadB] extends infer M extends [object, object] + ? [MergeNarrowObject, ..._MergeNarrowTuple] + : [HeadA & HeadB, ..._MergeNarrowTuple] + : [HeadA, ...RestA] + : []; + +type MergeNarrowTuple = + A['length'] extends 0 + ? B + : B['length'] extends 0 + ? A + : true extends GreaterThan + ? _MergeNarrowTuple + : _MergeNarrowTuple; + +type _MergeNarrowObject = + LastOfUnion extends infer K + ? K extends (keyof A) & (keyof B) + ? _MergeNarrowObject, Simplify, A & B>>> + : K extends keyof A + ? _MergeNarrowObject, Simplify>> + : K extends keyof B + ? _MergeNarrowObject, Simplify>> + : R + : never; + +type MergeNarrowObject = + Or, IsEqual> extends true + ? B + : Or, IsEqual> extends true + ? A + // Not Intersection. + : _MergeNarrowObject | KeysOfUnion) extends infer K extends (keyof A | keyof B) ? K : never>; diff --git a/test-d/pick-deep.ts b/test-d/pick-deep.ts index 556fefc81..de805561a 100644 --- a/test-d/pick-deep.ts +++ b/test-d/pick-deep.ts @@ -1,5 +1,5 @@ import {expectType} from 'tsd'; -import type {PickDeep} from '../index.d.ts'; +import type {IsEqual, PickDeep} from '../index.d.ts'; declare class ClassA { a: string; @@ -32,8 +32,9 @@ type Testing = BaseType & { 2?: BaseType; }; -declare const normal: PickDeep; -expectType<{string: string}>(normal); +type normal_Actual = PickDeep; +type normal_Expected = {string: string}; +expectType({} as IsEqual); type DeepType = { nested: { @@ -48,8 +49,8 @@ type DeepType = { }; type DepthType = {nested: {deep: {deeper: {value: string}}}}; -declare const deep: PickDeep; -expectType(deep); +type deep_Actual = PickDeep; +expectType({} as IsEqual); // Test interface // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -59,70 +60,131 @@ interface DeepInterface extends DeepType { string: string; }; } -declare const deepInterface: PickDeep; -expectType(deepInterface); -declare const deepInterface2: PickDeep; -expectType<{bar: {number: number}}>(deepInterface2); +type deepInterface_Actual = PickDeep; +expectType({} as IsEqual); +type deepInterface2_Actual = PickDeep; +type deepInterface2_Expected = {bar: {number: number}}; +expectType({} as IsEqual); type GenericType = { genericKey: T; }; -declare const genericTest: PickDeep, 'genericKey'>; -expectType<{genericKey: number}>(genericTest); - -declare const union: PickDeep; -expectType<{object: {number: number} & {string: string}}>(union); - -declare const optional: PickDeep; -expectType<{optionalObject?: {optionalString?: string}}>(optional); - -declare const optionalUnion: PickDeep; -expectType<{optionalObject?: {string?: string}; object: {number: number}}>(optionalUnion); - -declare const readonlyTest: PickDeep; -expectType<{readonly readonlyObject: {a: 1}}>(readonlyTest); - -declare const array: PickDeep; -expectType<{object: {array: number[]}}>(array); - -declare const readonlyArray: PickDeep; -expectType<{object: {readonlyArray: readonly number[]}}>(readonlyArray); - -declare const tuple: PickDeep; -expectType<{object: {tuples: ['foo', 'bar']}}>(tuple); - -declare const objectArray1: PickDeep; -expectType<{object: {objectArray: Array<{a: 1; b: 2}>}}>(objectArray1); - -declare const objectArray2: PickDeep; -expectType<{object: {objectArray: Array<{a: 1}>}}>(objectArray2); - -declare const leadingSpreadArray1: PickDeep; -expectType<{object: {leadingSpreadArray: [...Array<{a: 1}>]}}>(leadingSpreadArray1); - -declare const leadingSpreadArray2: PickDeep; -expectType<{object: {leadingSpreadArray: [...Array<{a: 1}>, {b: 2}]}}>(leadingSpreadArray2); - -declare const tailingSpreadArray1: PickDeep; -expectType<{object: {tailingSpreadArray: [unknown, {b: {c: 2; other: 2}}]}}>(tailingSpreadArray1); - -declare const tailingSpreadArray2: PickDeep; -expectType<{object: {tailingSpreadArray: [unknown, {b: {c: 2}}]}}>(tailingSpreadArray2); - -declare const date: PickDeep; -expectType<{object: {date: Date}}>(date); - -declare const instance: PickDeep; -expectType<{object: {instance: ClassA}}>(instance); - -declare const classTest: PickDeep; -expectType<{object: {Class: typeof ClassA}}>(classTest); - -declare const numberTest: PickDeep; -expectType<{1: BaseType}>(numberTest); - -declare const numberTest2: PickDeep; -expectType<{1: {0: number}}>(numberTest2); - -declare const numberTest3: PickDeep; -expectType<{2?: {0: number}}>(numberTest3); +type genericTest_Actual = PickDeep, 'genericKey'>; +type genericTest_Expected = {genericKey: number}; +expectType({} as IsEqual); + +type union_Actual = PickDeep; +type union_Expected = {object: {number: number; string: string}}; +expectType({} as IsEqual); + +type optional_Actual = PickDeep; +type optional_Expected = {optionalObject?: {optionalString?: string}}; +expectType({} as IsEqual); + +type optionalUnion_Actual = PickDeep; +type optionalUnion_Expected = { + optionalObject?: {string?: string}; + object: {number: number}; +}; +expectType({} as IsEqual); + +type readonlyTest_Actual = PickDeep; +type readonlyTest_Expected = {readonly readonlyObject: {a: 1}}; +expectType({} as IsEqual); + +type array_Actual = PickDeep; +type array_Expected = {object: {array: number[]}}; +expectType({} as IsEqual); + +type readonlyArray_Actual = PickDeep; +type readonlyArray_Expected = {object: {readonlyArray: readonly number[]}}; +expectType({} as IsEqual); + +type tuple_Actual = PickDeep; +type tuple_Expected = {object: {tuples: ['foo', 'bar']}}; +expectType({} as IsEqual); + +type objectArray1_Actual = PickDeep; +type objectArray1_Expected = {object: {objectArray: Array<{a: 1; b: 2}>}}; +expectType({} as IsEqual); + +type objectArray2_Actual = PickDeep; +type objectArray2_Expected = {object: {objectArray: Array<{a: 1}>}}; +expectType({} as IsEqual); + +type leadingSpreadArray1_Actual = PickDeep; +type leadingSpreadArray1_Expected = {object: {leadingSpreadArray: [...Array<{a: 1}> ]}}; +expectType({} as IsEqual); + +type leadingSpreadArray2_Actual = PickDeep; +type leadingSpreadArray2_Expected = {object: {leadingSpreadArray: [...Array<{a: 1}>, {b: 2}]}}; +expectType({} as IsEqual); + +type tailingSpreadArray1_Actual = PickDeep; +type tailingSpreadArray1_Expected = {object: {tailingSpreadArray: [unknown, {b: {c: 2; other: 2}}]}}; +expectType({} as IsEqual); + +type tailingSpreadArray2_Actual = PickDeep; +type tailingSpreadArray2_Expected = {object: {tailingSpreadArray: [unknown, {b: {c: 2}}]}}; +expectType({} as IsEqual); + +type date_Actual = PickDeep; +type date_Expected = {object: {date: Date}}; +expectType({} as IsEqual); + +// The `PickDeep` test for a property containing a class instance (ClassA). +type instance_Actual = PickDeep; +type instance_Expected = {object: {instance: ClassA}}; +expectType({} as IsEqual); + +type classTest_Actual = PickDeep; +type classTest_Expected = {object: {Class: typeof ClassA}}; +expectType({} as IsEqual); + +type numberTest_Actual = PickDeep; +type numberTest_Expected = {1: BaseType}; +expectType({} as IsEqual); + +type numberTest2_Actual = PickDeep; +type numberTest2_Expected = {1: {0: number}}; +expectType({} as IsEqual); + +type notEqual_NumberTest2_Actual0 = PickDeep; +type notEqual_NumberTest2_Expected0 = PickDeep; +expectType({} as IsEqual); + +type numberTest3_Actual = PickDeep; +type numberTest3_Expected = {2?: {0: number}}; +expectType({} as IsEqual); + +// Test for https://github.com/sindresorhus/type-fest/issues/1224 +type unionElement0_Actual = PickDeep<{obj: string | {a: string; b: number; c: boolean} | null | undefined}, 'obj'>; +type unionElement0_Expected = {obj: string | {a: string; b: number; c: boolean} | null | undefined}; +expectType({} as IsEqual); + +// Test for https://github.com/sindresorhus/type-fest/issues/1224 +type unionElement1_Actual = PickDeep<{obj: string | {a: string; b: number; c: {d: 'result'}} | null | undefined}, 'obj.b'>; +type unionElement1_Expected = {obj: string | null | undefined | {b: number}}; +expectType({} as IsEqual); + +// Test for https://github.com/sindresorhus/type-fest/issues/1224 +type unionElement2_Actual = PickDeep<{obj: string | {a: string; b: number; c: {readonly d?: 'result'}} | null | undefined}, 'obj.c.d'>; +type unionElement2_Expected = {obj: string | null | undefined | {c: {readonly d?: 'result'}}}; +expectType({} as IsEqual); + +// Test for https://github.com/sindresorhus/type-fest/issues/1224 +type unionElement3_Actual = PickDeep< + {obj: string | {a: string; b: number; c?: {readonly d?: 'result' | 'is'}} | null | undefined}, 'obj.c.d'>; +type unionElement3_Expected = {obj: string | null | undefined | {c?: {readonly d?: 'result' | 'is'}}}; +expectType({} as IsEqual); + +// Test for https://github.com/sindresorhus/type-fest/issues/1224 +type unionElement4_Actual = PickDeep< + {obj: string | {a: string; b: number; c?: {readonly d?: 'result' | 'is'}} | null | undefined}, 'obj.c.d' | 'obj.b'>; +type unionElement4_Expected = {obj: string | null | undefined | {c?: {readonly d?: 'result' | 'is'}; b: number}}; +expectType({} as IsEqual); + +// Test for https://github.com/sindresorhus/type-fest/issues/1140#issuecomment-2881382244 +type unionObject0_Actual = PickDeep<{foo: string} | {foo: number}, 'foo'>; +type unionObject0_Expected = {foo: string} | {foo: number}; +expectType({} as IsEqual); From 5303a237e7bb267e22587eb525ba27adc78e0335 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 27 Sep 2025 23:26:13 +0900 Subject: [PATCH 02/16] Update pick-deep.ts --- test-d/pick-deep.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-d/pick-deep.ts b/test-d/pick-deep.ts index de805561a..d00c1c61c 100644 --- a/test-d/pick-deep.ts +++ b/test-d/pick-deep.ts @@ -113,7 +113,7 @@ type objectArray2_Expected = {object: {objectArray: Array<{a: 1}>}}; expectType({} as IsEqual); type leadingSpreadArray1_Actual = PickDeep; -type leadingSpreadArray1_Expected = {object: {leadingSpreadArray: [...Array<{a: 1}> ]}}; +type leadingSpreadArray1_Expected = {object: {leadingSpreadArray: [...Array<{a: 1}>]}}; expectType({} as IsEqual); type leadingSpreadArray2_Actual = PickDeep; From a38415e304e209e9e67d6aaa7e5daa54bbccacff Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Sun, 28 Sep 2025 02:46:00 +0900 Subject: [PATCH 03/16] Fix #1223 --- source/pick-deep.d.ts | 16 ++++++++-------- test-d/pick-deep.ts | 9 +++++++++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/source/pick-deep.d.ts b/source/pick-deep.d.ts index 78b4c2503..f43bacadd 100644 --- a/source/pick-deep.d.ts +++ b/source/pick-deep.d.ts @@ -185,13 +185,7 @@ Assuming `T` is a union type: Here is an example that explains why tuples are merged via intersection and objects via union. type testMergeNarrow_0 = MergeNarrow - -// string -// | number -// | { a: number; -// d: [0, 1]; -// b: 199 | {readonly c: unknown;}} -// | [0, 1, [2, 3, unknown], {x: unknown; y: 2}] +// ^ string | number | { a: number; d: [0, 1]; b: 199 | {readonly c: unknown;}} | [0, 1, [2, 3, unknown], {x: unknown; y: 2}] */ type MergeNarrow = LastOfUnion extends infer L @@ -215,8 +209,14 @@ type _MergeNarrowTuple = ? [MergeNarrowObject, ..._MergeNarrowTuple] : [HeadA & HeadB, ..._MergeNarrowTuple] : [HeadA, ...RestA] - : []; + // For https://github.com/sindresorhus/type-fest/issues/1223 + : [A, B] extends [Array, Array] + ? Array> + : []; +/* +If A is longer than B, fill the rest with A's elements, so position matters. +*/ type MergeNarrowTuple = A['length'] extends 0 ? B diff --git a/test-d/pick-deep.ts b/test-d/pick-deep.ts index d00c1c61c..bc2d468b6 100644 --- a/test-d/pick-deep.ts +++ b/test-d/pick-deep.ts @@ -188,3 +188,12 @@ expectType({} as IsEqual); type unionObject0_Actual = PickDeep<{foo: string} | {foo: number}, 'foo'>; type unionObject0_Expected = {foo: string} | {foo: number}; expectType({} as IsEqual); + +// Test for https://github.com/sindresorhus/type-fest/issues/1223 +type unionKeyObjectArray_Actual = PickDeep<{arr: Array<{a: string; b: number; c: boolean}>}, `arr.${number}.${'b' | 'c'}`>; +type unionKeyObjectArray_Expected = {arr: Array<{b: number; c: boolean}>}; +expectType({} as IsEqual); + +type unionKeyObjectArrayArray_Actual = PickDeep<{arr: Array>}, `arr.${number}.${number}.${'b' | 'c'}`>; +type unionKeyObjectArrayArray_Expected = {arr: Array>}; +expectType({} as IsEqual); From a8ac3954ea1df8c5f95672afb254b32e14750c09 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Mon, 29 Sep 2025 08:57:07 +0900 Subject: [PATCH 04/16] Fix: union type including same shape pbjects --- source/is-equal.d.ts | 10 +- source/pick-deep.d.ts | 383 +++++++++++++++++++++++++++--------------- test-d/pick-deep.ts | 12 +- 3 files changed, 264 insertions(+), 141 deletions(-) diff --git a/source/is-equal.d.ts b/source/is-equal.d.ts index 646f0fb7b..0bb0d9d51 100644 --- a/source/is-equal.d.ts +++ b/source/is-equal.d.ts @@ -26,8 +26,14 @@ type Includes = @category Utilities */ export type IsEqual = - [A, B] extends [infer A, infer B] - ? _IsEqual + [A, B] extends [infer AA, infer BB] + ? [AA] extends [never] + ? [BB] extends [never] + ? true + : false + : [BB] extends [never] + ? false + : _IsEqual : false; // This version fails the `equalWrappedTupleIntersectionToBeNeverAndNeverExpanded` test in `test-d/is-equal.ts`. diff --git a/source/pick-deep.d.ts b/source/pick-deep.d.ts index f43bacadd..8467d0df0 100644 --- a/source/pick-deep.d.ts +++ b/source/pick-deep.d.ts @@ -1,16 +1,181 @@ -import type {Get} from './get.d.ts'; import type {TupleOf} from './tuple-of.d.ts'; -import type {BuildObject, NonRecursiveType, ObjectValue} from './internal/index.d.ts'; -import type {IsNever} from './is-never.d.ts'; +import type {BuildObject} from './internal/index.d.ts'; import type {Paths} from './paths.d.ts'; import type {Simplify} from './simplify.d.ts'; import type {GreaterThan} from './greater-than.d.ts'; import type {IsEqual} from './is-equal.d.ts'; import type {Or} from './or.d.ts'; +import type {IsNegative} from './numeric.d.ts'; +import type {IsTuple} from './is-tuple.d.ts'; import type {KeysOfUnion} from './keys-of-union.d.ts'; import type {UnionToIntersection} from './union-to-intersection.d.ts'; import type {UnknownArray} from './unknown-array.d.ts'; +/** +`Pick<{0: 0}, '0'>` does not work as expected in some cases, but `ForcePick` handles both `string` and `number` keys correctly. + +type Test_ForcePick_0 = ForcePick<[0,1], 1>; // 1 +type Test_ForcePick_1 = ForcePick<{0: 'aa'}, '0'>; // {0: 'aa'} +type Test_ForcePick_2 = ForcePick<{'0': 'aa'}, '0'>; // {'0': 'aa'} +type Test_ForcePick_3 = ForcePick<{0: 'aa'}, 0>; // {0: 'aa'} +type Test_ForcePick_4 = ForcePick<{'0': 'aa'}, 0>; // {'0': 'aa'} +*/ +type ForcePick = + Collection extends UnknownArray + ? Collection[Key extends keyof Collection ? Key : never] + : {[K in keyof Collection as `${K extends (string | number) ? K : never}` extends `${Key}` ? K : never]: Collection[K]}; + +/** +Forcefully access the property value of `T` by Key if it exists, supporting both `string` and `number` representations; otherwise, return `never`. + +type TestForceGet_0 = ForceGet<{2: 'x'}, '2'>; // 'x' +type TestForceGet_1 = ForceGet<{'2': 'x'}, '2'>; // 'x' +type TestForceGet_2 = ForceGet<{2: 'x'}, 2>; // 'x' +type TestForceGet_3 = ForceGet<{'2': 'x'}, 2>; // 'x' +type TestForceGet_4 = ForceGet<['x', 'xx'], 0>; // 'x' +type TestForceGet_5 = ForceGet<['x', 'xx'], '0'>; // 'x' +type TestForceGet_6 = ForceGet<['x', 'xx'], '2'> // never +type TestForceGet_7 = ForceGet<{a: 'x'}, 'b'> // never +type TestForceGet_8 = ForceGet<[0,1,2], `${number}`> // 0 | 1 | 2 +type TestForceGet_9 = ForceGet // string +*/ +type ForceGet = + Key extends keyof T + ? T[Key] + : Key extends string | number + ? StringToNumber<`${Key}`> extends infer Number_ extends number + ? IsNever extends false + ? `${Key}` extends `${keyof T extends (string | number) ? keyof T : never}` + ? T[(`${Key}` extends infer K extends keyof T ? K : never)] | T[(Number_ extends infer K extends keyof T ? K : never)] + /* `${number}` case */ + : `${Key}` extends `${infer N extends number}` + ? [IsEqual, T] extends [true, Array] + ? ArrayType + : never + : never + : never + : never + : never; + +/** +Converts a `string` to a `number` if possible; otherwise, returns `never`. + +type Test_StringToNumber_0 = StringToNumber<'s'>; // never +type Test_StringToNumber_1 = StringToNumber<'0'>; // 0 +*/ +type StringToNumber = T extends `${infer N extends number}` ? N : never; + +/** +Returns `true` if the key is theoretically accessible on the value `A`; otherwise, returns `false`. + +type Test_IsKeyOf_0 = IsKeyOf<[0, 1], 1>; // true +type Test_IsKeyOf_1 = IsKeyOf<[0, 1], 3>; // false +type Test_IsKeyOf_2 = IsKeyOf<[0, 1], -1>; // false +type Test_IsKeyOf_3 = IsKeyOf<[0, 1], 's'>; // false +type Test_IsKeyOf_4 = IsKeyOf<[0, 1], '1'>; // true +type Test_IsKeyOf_5 = IsKeyOf<[0, 1], '3'>; // false +type Test_IsKeyOf_6 = IsKeyOf<[0, 1], '-1'>; // false +type Test_IsKeyOf_7 = IsKeyOf<[0, 1], `${number}`>; // true +type Test_IsKeyOf_8 = IsKeyOf, `${number}`>; // true +type Test_IsKeyOf_9 = IsKeyOf<{0: 'a0'}, 0>; // true +type Test_IsKeyOf_10 = IsKeyOf<{'0': 'a0'}, 0>; // true +type Test_IsKeyOf_11 = IsKeyOf<{0: 'a0'}, '0'>; // true +type Test_IsKeyOf_12 = IsKeyOf<{'0': 'a0'}, 0>; // true +*/ +type IsKeyOf = + A extends UnknownArray + ? IsEqual, true> extends false + ? K extends `${number}` + ? true + : K extends (string | number) + ? IsEqual>> + : never + : K extends (string | number) + ? [A['length'], IsEqual, IsEqual>>, GreaterThan>] extends [number, false, true, true] + ? true + : false + : false + : A extends object + ? K extends (string | number) + ? `${K}` extends `${keyof A extends (string | number) ? keyof A : never}` + ? true + : false + : false + : false; + +/** +Returns `A` itself if it is not an `object`. +If `A` is an `object`, behaves like `Pick`. +If `A` is an `UnknownArray`, returns `A[K]`. + +This fixes https://github.com/sindresorhus/type-fest/issues/1224. + +type Test_PickOrSelf_0 = PickOrSelf; // string +type Test_PickOrSelf_1 = PickOrSelf<{readonly a?: 0}, 'a'>; // {readonly a?: 0} +type Test_PickOrSelf_2 = PickOrSelf<{2?: 0}, '2'>; // {2?: 0} +type Test_PickOrSelf_3 = PickOrSelf<{2?: 0}, 2>; // {2?: 0} +type Test_PickOrSelf_4 = PickOrSelf<{'2': 0}, 2>; // {'2': 0} +*/ +type PickOrSelf = + A extends UnknownArray + ? ForcePick + : A extends object + ? IsEqual> extends true + ? ForcePick + : never + : A; + +type LastOfUnion = UnionToIntersection T : never> extends () => (infer R) ? R : never; + +/** +This version fails the `equalWrappedTupleIntersectionToBeNeverAndNeverExpanded` test in `test-d/is-equal.ts`. +*/ +type _IsEqual = + (() => G extends A & G | G ? 1 : 2) extends + (() => G extends B & G | G ? 1 : 2) + ? true + : false; + +type IsNever = _IsEqual; + +/** +Merges only the `object` types from a union; otherwise, returns the value as-is. + +type Test_MergeOnlyObjectUnion_0 = MergeOnlyObjectUnion<0 | string | {readonly a: 0} | {b?: 2} | [0] | [1]>; // string | 0 | [0] | [1] | {readonly a: 0; b?: 2} +*/ +type MergeOnlyObjectUnion = _MergeOnlyObjectUnion; +type _MergeOnlyObjectUnion = + LastOfUnion extends infer L + ? IsNever extends false + ? L extends UnknownArray + ? _MergeOnlyObjectUnion, ObjectStack, UnionStack | L> + : L extends object + ? _MergeOnlyObjectUnion, ObjectStack & L, UnionStack> + : _MergeOnlyObjectUnion, ObjectStack, UnionStack | L> + : UnionStack | Simplify extends false ? ObjectStack : never> + : never; + +/** +This doesn't fail with non-object type, to safely support `keyof` with union types including `object`s. + +type Test_CoerceKeyof_0 = CoerceKeyof; +// "x" +*/ +type CoerceKeyof = R extends object ? keyof R extends (string | number) ? keyof R : never : never; + +/* `BuildObject` supporting `${number}`. */ +type Build = + `${K extends string ? K : never}` extends `${infer N extends number}` + ? IsEqual extends true + ? L[] + : M extends UnknownArray + ? [...TupleOf, L] + : BuildObject + : BuildObject; + +/* Just rename */ +type As = Extract; + /** Pick properties from a deeply-nested object. @@ -82,167 +247,109 @@ type Street = PickDeep; @category Object @category Array */ -export type PickDeep> = - T extends NonRecursiveType - ? never - : T extends UnknownArray - ? MergeNarrow<{[P in PathUnion]: InternalPickDeep}[PathUnion]> - : T extends object - ? MergeNarrow>> - : never; +export type PickDeep> = InternalPickDeep> extends infer M extends PathTreeType ? M : never>; -/** -Pick an object/array from the given object/array by one path. -*/ -type InternalPickDeep = - T extends NonRecursiveType - ? T - : T extends UnknownArray ? PickDeepArray - : T extends object ? Simplify> - : T; - -type _PickDeepObject = - ObjectValue extends infer ObjectV - ? IsNever extends false - ? BuildObject - : never - : never; +type InternalPickDeep = + Parent extends UnknownArray + ? _PickDeep + : Parent extends object + ? _PickDeep + : Parent; -/** -Pick an object from the given object by one path. -*/ -type PickDeepObject = - P extends `${infer RecordKeyInPath}.${infer SubPath}` - // `ObjectV` doesn't extends `(UnknownArray | object)` when the union type includes members that don't satisfy that constraint. - // In such cases, `InternalPickDeep` returns the original type itself. - // This allows union types to preserve their structure. - ? ObjectValue extends infer ObjectV - ? IsNever extends false - ? SubPath extends `${infer _MainSubPath}.${infer _NextSubPath}` - ? BuildObject, ObjectV extends (UnknownArray | object) ? ObjectV : never> - : ObjectV extends UnknownArray - ? Simplify, RecordType>> - : Simplify, SubPath>, RecordType>> +type RecursionPickDeep = + NextParent extends infer NextParentArray extends UnknownArray + /* NextParent: array */ + ? IsEqual, false> extends true + ? NextParentArray extends Array + /* If end */ + ? ForceGet> extends LeafMarker + ? IsEqual<`${number}`, `${CoerceKeyof}`> extends true + /* `leadingSpreadArray2_Actual` in `test-d/pick-deep.ts` */ + ? NextParent + /* `tailingSpreadArray1_Actual` in `test-d/pick-deep.ts` */ + : [...TupleOf}`>>, PickOrSelf>] + /* Not end */ + : InternalPickDeep>, ForceGet> extends infer G extends PathTreeType ? G : never> extends infer Result + ? IsEqual<`${number}`, `${CoerceKeyof}`> extends true + /* `leadingSpreadArray1_Actual` in `test-d/pick-deep.ts` */ + ? Result[] + /* `tailingSpreadArray2_Actual` in `test-d/pick-deep.ts`. */ + : [...TupleOf}`>>, Result] + : never : never - : never - // Case where the path is not concatenated. - : Simplify<_PickDeepObject>; + /* NextParent: tuple */ + : InternalPickDeep + /* NextParent: object */ + : InternalPickDeep>>>>>, NextPathTree>; -/** -Pick an array from the given array by one path. -*/ -type PickDeepArray = - // Handle paths that are `${number}.${string}` - P extends `${infer ArrayIndex extends number}.${infer SubPath}` - // When `ArrayIndex` is equal to `number` - ? number extends ArrayIndex - ? ArrayType extends unknown[] - ? Array, SubPath>> - : ArrayType extends readonly unknown[] - ? ReadonlyArray, SubPath>> +type _PickDeep = + LastOfUnion extends infer L extends keyof PathTree + ? IsNever extends false + ? L extends number | string + ? IsEqual> extends true + /* Detect an end of path. */ + ? IsEqual extends true + ? PickOrSelf extends infer PickResult + ? PickResult extends UnknownArray + ? _PickDeep, U | Simplify<[...TupleOf>, PickResult]>> + : _PickDeep, U | Simplify> + : never + : _PickDeep, U | Simplify, As, PathTreeType>>, As>>> : never - // When `ArrayIndex` is a number literal - : ArrayType extends unknown[] - ? [...TupleOf, InternalPickDeep, SubPath>] - : ArrayType extends readonly unknown[] - ? readonly [...TupleOf, InternalPickDeep, SubPath>] - : never - // When the path is equal to `number` - : P extends `${infer ArrayIndex extends number}` - // When `ArrayIndex` is `number` - ? number extends ArrayIndex - ? ArrayType - // When `ArrayIndex` is a number literal - : ArrayType extends unknown[] - ? [...TupleOf, ArrayType[ArrayIndex]] - : ArrayType extends readonly unknown[] - ? readonly [...TupleOf, ArrayType[ArrayIndex]] - : never - : never; - -type _Pick = - {[k in keyof T as `${k extends (string | number) ? k : never}` extends `${Key}` ? k : never]: T[k]}; - -type IsKeyOf = `${k}` extends `${keyof a extends (string | number) ? keyof a : never}` ? true : false; - -type GetOrSelf = (a extends any ? IsEqual> extends true ? Get : a : never); - -type PickOrSelf = (a extends any ? IsEqual> extends true ? _Pick : a : never); + : never + : never + : MergeOnlyObjectUnion; -type LastOfUnion = -UnionToIntersection T : never> extends () => (infer R) - ? R - : never; +/** +Converts a dot-delimited path string into a nested object tree structure. +Example: `'a.b.c'` becomes `{a: {b: {c: ''}}}` -/* -This is a local function that merges multiple objects obtained as a union type when the path in `PickDeep` is a union type. +Type Test_PathToTree = MergeNarrow>; +// {d: {b: {d: ''; c: ''}}; +// a: {b: {d: {x: ''}; c: {x: ''}}}} +*/ +type PathToTree = + S extends `${infer F}.${infer Next}` + ? Next extends `${infer _}.${infer __}` + ? {[K in F]: PathToTree} + : {[K in F]: {[L in Next]: LeafMarker}} + : {[K in S extends string ? S : never]: LeafMarker}; -Assuming `T` is a union type: - - If a member `t` of `T` is not a collection type, it is returned as is. - - If `t` is an object, each property is narrowed via union, and `MergeNarrow` is applied to any property that is a collection (which would also be a union type). The results are then merged. - - The same logic applies to tuples, but merged via intersection. +type LeafMarker = ''; +type PathTreeType = {[K in string]: PathTreeType | LeafMarker}; -Here is an example that explains why tuples are merged via intersection and objects via union. +/** +Merges nested `object` trees from a union into a single tree structure. -type testMergeNarrow_0 = MergeNarrow -// ^ string | number | { a: number; d: [0, 1]; b: 199 | {readonly c: unknown;}} | [0, 1, [2, 3, unknown], {x: unknown; y: 2}] +type Test_MergeTree_0 = MergeTree<{a: {b: ''}} | {a: {c: ''}}>; // {a: {b: ''; c: '';}} */ -type MergeNarrow = +type MergeTree = LastOfUnion extends infer L ? IsNever extends false - ? L extends UnknownArray - ? MergeNarrow, MergeNarrowTuple, M> - : L extends object - ? MergeNarrow, R, MergeNarrowObject> - : L | MergeNarrow, R, M> - : IsEqual<[R, M], [[], {}]> extends true + ? L extends object + ? MergeTree, MergeTreeObject> + : L | MergeTree, M> + : IsEqual<[M], [{}]> extends true ? never - : R | M + : M : never; -type _MergeNarrowTuple = - A extends readonly [infer HeadA, ...infer RestA] - ? B extends readonly [infer HeadB, ...infer RestB] - ? [HeadA, HeadB] extends infer M extends [UnknownArray, UnknownArray] - ? [MergeNarrowTuple, ..._MergeNarrowTuple] - : [HeadA, HeadB] extends infer M extends [object, object] - ? [MergeNarrowObject, ..._MergeNarrowTuple] - : [HeadA & HeadB, ..._MergeNarrowTuple] - : [HeadA, ...RestA] - // For https://github.com/sindresorhus/type-fest/issues/1223 - : [A, B] extends [Array, Array] - ? Array> - : []; - -/* -If A is longer than B, fill the rest with A's elements, so position matters. -*/ -type MergeNarrowTuple = - A['length'] extends 0 - ? B - : B['length'] extends 0 - ? A - : true extends GreaterThan - ? _MergeNarrowTuple - : _MergeNarrowTuple; - -type _MergeNarrowObject = +type _MergeTreeObject = LastOfUnion extends infer K ? K extends (keyof A) & (keyof B) - ? _MergeNarrowObject, Simplify, A & B>>> + ? _MergeTreeObject, Simplify, A | B>>> : K extends keyof A - ? _MergeNarrowObject, Simplify>> + ? _MergeTreeObject, Simplify>> : K extends keyof B - ? _MergeNarrowObject, Simplify>> + ? _MergeTreeObject, Simplify>> : R : never; -type MergeNarrowObject = +type MergeTreeObject = Or, IsEqual> extends true ? B : Or, IsEqual> extends true ? A - // Not Intersection. - : _MergeNarrowObject | KeysOfUnion) extends infer K extends (keyof A | keyof B) ? K : never>; + : _MergeTreeObject | KeysOfUnion) extends infer K extends (keyof A | keyof B) ? K : never>; export {}; diff --git a/test-d/pick-deep.ts b/test-d/pick-deep.ts index bc2d468b6..587a51690 100644 --- a/test-d/pick-deep.ts +++ b/test-d/pick-deep.ts @@ -1,5 +1,5 @@ import {expectType} from 'tsd'; -import type {IsEqual, PickDeep} from '../index.d.ts'; +import type {IsEqual, PickDeep, Simplify} from '../index.d.ts'; declare class ClassA { a: string; @@ -60,8 +60,10 @@ interface DeepInterface extends DeepType { string: string; }; } + type deepInterface_Actual = PickDeep; expectType({} as IsEqual); + type deepInterface2_Actual = PickDeep; type deepInterface2_Expected = {bar: {number: number}}; expectType({} as IsEqual); @@ -197,3 +199,11 @@ expectType({} as IsEqual>}, `arr.${number}.${number}.${'b' | 'c'}`>; type unionKeyObjectArrayArray_Expected = {arr: Array>}; expectType({} as IsEqual); + +type unionSameKeysObject_Actual = PickDeep<{a: string | {b: 1 | true; c: 2; d: {g: {f: 9; h: 10}}} | {b: '1'; c: '2'}; x: 10 | 11; y: [[0, 1], 2, 3]}, `a.${'b' | 'c'}` | 'x'>; +type unionSameKeysObject_Expected = {x: 10 | 11; a: string | {b: 1 | true; c: 2} | {b: '1'; c: '2'}}; +expectType({} as IsEqual); + +type unionTupleTuple_Actual = PickDeep<[0, string | [1, [2]]], '1.1'>; +type unionTupleTuple_Expected = [unknown, string | [unknown, [2]]]; +expectType({} as IsEqual); From 809e36344cb79ffa752f30c2984bae20b3759e18 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Thu, 2 Oct 2025 00:39:24 +0900 Subject: [PATCH 05/16] Fix: internal LastOfUnion --- source/pick-deep.d.ts | 55 ++++++++++++++++++++----------------------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/source/pick-deep.d.ts b/source/pick-deep.d.ts index 8467d0df0..604189ec0 100644 --- a/source/pick-deep.d.ts +++ b/source/pick-deep.d.ts @@ -10,6 +10,7 @@ import type {IsTuple} from './is-tuple.d.ts'; import type {KeysOfUnion} from './keys-of-union.d.ts'; import type {UnionToIntersection} from './union-to-intersection.d.ts'; import type {UnknownArray} from './unknown-array.d.ts'; +import type {IsNever} from './is-never.d.ts'; /** `Pick<{0: 0}, '0'>` does not work as expected in some cases, but `ForcePick` handles both `string` and `number` keys correctly. @@ -125,18 +126,12 @@ type PickOrSelf = : never : A; -type LastOfUnion = UnionToIntersection T : never> extends () => (infer R) ? R : never; - -/** -This version fails the `equalWrappedTupleIntersectionToBeNeverAndNeverExpanded` test in `test-d/is-equal.ts`. -*/ -type _IsEqual = - (() => G extends A & G | G ? 1 : 2) extends - (() => G extends B & G | G ? 1 : 2) - ? true - : false; - -type IsNever = _IsEqual; +type LastOfUnion = + IsEqual extends true + ? never + : UnionToIntersection T : never> extends () => (infer R) + ? R + : never; /** Merges only the `object` types from a union; otherwise, returns the value as-is. @@ -269,7 +264,7 @@ type RecursionPickDeep = /* `tailingSpreadArray1_Actual` in `test-d/pick-deep.ts` */ : [...TupleOf}`>>, PickOrSelf>] /* Not end */ - : InternalPickDeep>, ForceGet> extends infer G extends PathTreeType ? G : never> extends infer Result + : InternalPickDeep>, As>, PathTreeType>> extends infer Result ? IsEqual<`${number}`, `${CoerceKeyof}`> extends true /* `leadingSpreadArray1_Actual` in `test-d/pick-deep.ts` */ ? Result[] @@ -297,8 +292,8 @@ type _PickDeep, U | Simplify, As, PathTreeType>>, As>>> : never : never - : never - : MergeOnlyObjectUnion; + : MergeOnlyObjectUnion + : never; /** Converts a dot-delimited path string into a nested object tree structure. @@ -323,26 +318,26 @@ Merges nested `object` trees from a union into a single tree structure. type Test_MergeTree_0 = MergeTree<{a: {b: ''}} | {a: {c: ''}}>; // {a: {b: ''; c: '';}} */ -type MergeTree = - LastOfUnion extends infer L - ? IsNever extends false - ? L extends object - ? MergeTree, MergeTreeObject> - : L | MergeTree, M> - : IsEqual<[M], [{}]> extends true +type MergeTree = + LastOfUnion extends infer L extends object + ? IsNever extends false + ? MergeTree, MergeTreeObject> + : IsEqual extends true ? never : M : never; type _MergeTreeObject = LastOfUnion extends infer K - ? K extends (keyof A) & (keyof B) - ? _MergeTreeObject, Simplify, A | B>>> - : K extends keyof A - ? _MergeTreeObject, Simplify>> - : K extends keyof B - ? _MergeTreeObject, Simplify>> - : R + ? IsNever extends false + ? K extends (keyof A) & (keyof B) + ? _MergeTreeObject, Simplify, As>, A | B>>> + : K extends keyof A + ? _MergeTreeObject, Simplify>> + : K extends keyof B + ? _MergeTreeObject, Simplify>> + : R + : R : never; type MergeTreeObject = @@ -350,6 +345,6 @@ type MergeTreeObject = ? B : Or, IsEqual> extends true ? A - : _MergeTreeObject | KeysOfUnion) extends infer K extends (keyof A | keyof B) ? K : never>; + : _MergeTreeObject | KeysOfUnion), (keyof A | keyof B)>>; export {}; From dce5b645e90558b834af2e776625f8873320aa53 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Fri, 10 Oct 2025 18:38:34 +0900 Subject: [PATCH 06/16] refactor: LastOfUnion moved to internal --- source/internal/type.d.ts | 48 ++++++++++++++++++++++++++++++++++++++ source/pick-deep.d.ts | 10 +------- source/union-to-tuple.d.ts | 26 ++++++--------------- test-d/pick-deep.ts | 2 +- test-d/union-to-tuple.ts | 2 ++ 5 files changed, 59 insertions(+), 29 deletions(-) diff --git a/source/internal/type.d.ts b/source/internal/type.d.ts index 24c8baa16..da8376c7e 100644 --- a/source/internal/type.d.ts +++ b/source/internal/type.d.ts @@ -2,6 +2,8 @@ import type {If} from '../if.d.ts'; import type {IsAny} from '../is-any.d.ts'; import type {IsNever} from '../is-never.d.ts'; import type {Primitive} from '../primitive.d.ts'; +import type {IsEqual} from '../is-equal.d.ts'; +import type {UnionToIntersection} from '../union-to-intersection.d.ts'; /** Matches any primitive, `void`, `Date`, or `RegExp` value. @@ -129,4 +131,50 @@ export type IsExactOptionalPropertyTypesEnabled = [(string | undefined)?] extend ? false : true; +/** +Returns the last element of a union type; otherwise `never` if `never` passed. +Note that this is non-deterministic because the order of union type is not guaranteed. + +@see https://github.com/microsoft/TypeScript/issues/13298#issuecomment-468375328 + +This can be used to implement a recursive type function that accepts a union type. +It can detect a termination case using {@link IsNever `IsNever`}. + +@example +``` +type RecursionType = + LastOfUnion extends infer L + ? IsNever extends false + ? RecursionType, [...R, L]> + : R + : never; + +type RecursionTest = RecursionType +//=> [string, number] +``` + +@example +``` +export type UnionToTuple> = + IsNever extends false + ? [...UnionToTuple>, L] + : []; +``` + +@example +``` +type Last = LastOfUnion<1 | 2 | 3>; +//=> 3 + +type LastNever = LastOfUnion; +//=> never +``` +*/ +export type LastOfUnion = + IsEqual extends true + ? never + : UnionToIntersection T : never> extends () => (infer R) + ? R + : never; + export {}; diff --git a/source/pick-deep.d.ts b/source/pick-deep.d.ts index 604189ec0..2a5e7ec03 100644 --- a/source/pick-deep.d.ts +++ b/source/pick-deep.d.ts @@ -1,5 +1,5 @@ import type {TupleOf} from './tuple-of.d.ts'; -import type {BuildObject} from './internal/index.d.ts'; +import type {BuildObject, LastOfUnion} from './internal/index.d.ts'; import type {Paths} from './paths.d.ts'; import type {Simplify} from './simplify.d.ts'; import type {GreaterThan} from './greater-than.d.ts'; @@ -8,7 +8,6 @@ import type {Or} from './or.d.ts'; import type {IsNegative} from './numeric.d.ts'; import type {IsTuple} from './is-tuple.d.ts'; import type {KeysOfUnion} from './keys-of-union.d.ts'; -import type {UnionToIntersection} from './union-to-intersection.d.ts'; import type {UnknownArray} from './unknown-array.d.ts'; import type {IsNever} from './is-never.d.ts'; @@ -126,13 +125,6 @@ type PickOrSelf = : never : A; -type LastOfUnion = - IsEqual extends true - ? never - : UnionToIntersection T : never> extends () => (infer R) - ? R - : never; - /** Merges only the `object` types from a union; otherwise, returns the value as-is. diff --git a/source/union-to-tuple.d.ts b/source/union-to-tuple.d.ts index d06bf8aab..e466f0161 100644 --- a/source/union-to-tuple.d.ts +++ b/source/union-to-tuple.d.ts @@ -1,19 +1,5 @@ import type {IsNever} from './is-never.d.ts'; -import type {UnionToIntersection} from './union-to-intersection.d.ts'; - -/** -Returns the last element of a union type. - -@example -``` -type Last = LastOfUnion<1 | 2 | 3>; -//=> 3 -``` -*/ -type LastOfUnion = -UnionToIntersection T : never> extends () => (infer R) - ? R - : never; +import type {LastOfUnion} from './internal/index.d.ts'; /** Convert a union type into an unordered tuple type of its elements. @@ -50,9 +36,11 @@ const petList = Object.keys(pets) as UnionToTuple; @category Array */ -export type UnionToTuple> = -IsNever extends false - ? [...UnionToTuple>, L] - : []; +export type UnionToTuple = _UnionToTuple; + +type _UnionToTuple> = + IsNever extends false + ? [..._UnionToTuple>, L] + : []; export {}; diff --git a/test-d/pick-deep.ts b/test-d/pick-deep.ts index 587a51690..70572e88f 100644 --- a/test-d/pick-deep.ts +++ b/test-d/pick-deep.ts @@ -1,5 +1,5 @@ import {expectType} from 'tsd'; -import type {IsEqual, PickDeep, Simplify} from '../index.d.ts'; +import type {IsEqual, PickDeep} from '../index.d.ts'; declare class ClassA { a: string; diff --git a/test-d/union-to-tuple.ts b/test-d/union-to-tuple.ts index 12de3cf2b..6c5392272 100644 --- a/test-d/union-to-tuple.ts +++ b/test-d/union-to-tuple.ts @@ -11,3 +11,5 @@ expectType({} as (1 | 2 | 3)); type Options2 = UnionToTuple; expectType({} as (1 | false | true)); + +expectType<[]>({} as UnionToTuple); From 82e8590552d94d0f6d2ef8a147bc35398c02614e Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Fri, 10 Oct 2025 18:49:59 +0900 Subject: [PATCH 07/16] refactor: Using IsNever instead of IsEqual --- source/pick-deep.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/pick-deep.d.ts b/source/pick-deep.d.ts index 2a5e7ec03..c367976d3 100644 --- a/source/pick-deep.d.ts +++ b/source/pick-deep.d.ts @@ -333,9 +333,9 @@ type _MergeTreeObject = - Or, IsEqual> extends true + Or, IsEqual> extends true ? B - : Or, IsEqual> extends true + : Or, IsEqual> extends true ? A : _MergeTreeObject | KeysOfUnion), (keyof A | keyof B)>>; From cab866725d2693ec4580d7cfb2a140ef8b216908 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 10 Oct 2025 20:41:21 +0900 Subject: [PATCH 08/16] Update pick-deep.d.ts --- source/pick-deep.d.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/source/pick-deep.d.ts b/source/pick-deep.d.ts index c367976d3..bef2734a3 100644 --- a/source/pick-deep.d.ts +++ b/source/pick-deep.d.ts @@ -34,9 +34,9 @@ type TestForceGet_2 = ForceGet<{2: 'x'}, 2>; // 'x' type TestForceGet_3 = ForceGet<{'2': 'x'}, 2>; // 'x' type TestForceGet_4 = ForceGet<['x', 'xx'], 0>; // 'x' type TestForceGet_5 = ForceGet<['x', 'xx'], '0'>; // 'x' -type TestForceGet_6 = ForceGet<['x', 'xx'], '2'> // never -type TestForceGet_7 = ForceGet<{a: 'x'}, 'b'> // never -type TestForceGet_8 = ForceGet<[0,1,2], `${number}`> // 0 | 1 | 2 +type TestForceGet_6 = ForceGet<['x', 'xx'], '2'>; // never +type TestForceGet_7 = ForceGet<{a: 'x'}, 'b'>; // never +type TestForceGet_8 = ForceGet<[0,1,2], `${number}`>; // 0 | 1 | 2 type TestForceGet_9 = ForceGet // string */ type ForceGet = @@ -291,7 +291,7 @@ type _PickDeep>; +type Test_PathToTree = MergeTree>; // {d: {b: {d: ''; c: ''}}; // a: {b: {d: {x: ''}; c: {x: ''}}}} */ From 4e7fa628c92ef13283735374a9a43a2e0575016e Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Thu, 15 Jan 2026 08:46:52 +0900 Subject: [PATCH 09/16] chore: sync with main --- .github/contributing.md | 2 +- .github/workflows/claude-code-review.yml | 45 + .github/workflows/claude.yml | 36 + .github/workflows/main.yml | 12 +- .github/workflows/ts-canary.yml | 2 +- CLAUDE.md | 115 ++ index.d.ts | 11 + lint-processors/fixtures/eslint.config.js | 62 + lint-processors/jsdoc-codeblocks.js | 172 ++ lint-processors/jsdoc-codeblocks.test.js | 1481 ++++++++++++++++++ lint-rules/import-path.js | 7 +- lint-rules/import-path.test.js | 132 ++ lint-rules/require-export.test.js | 109 +- lint-rules/require-exported-types.test.js | 544 +------ lint-rules/source-files-extension.test.js | 40 + lint-rules/test-utils.js | 313 ++++ lint-rules/validate-jsdoc-codeblocks.js | 360 +++++ lint-rules/validate-jsdoc-codeblocks.test.js | 1313 ++++++++++++++++ package.json | 13 +- readme.md | 36 +- source/all-extend.d.ts | 15 +- source/all-union-fields.d.ts | 17 +- source/and.d.ts | 2 + source/array-element.d.ts | 46 + source/array-reverse.d.ts | 84 + source/array-slice.d.ts | 39 +- source/array-splice.d.ts | 16 +- source/array-tail.d.ts | 2 +- source/arrayable.d.ts | 4 +- source/async-return-type.d.ts | 9 +- source/asyncify.d.ts | 23 +- source/camel-case.d.ts | 13 +- source/camel-cased-properties-deep.d.ts | 12 +- source/camel-cased-properties.d.ts | 8 +- source/characters.d.ts | 11 +- source/conditional-except.d.ts | 12 +- source/conditional-pick-deep.d.ts | 10 +- source/conditional-pick.d.ts | 12 +- source/conditional-simplify-deep.d.ts | 8 +- source/conditional-simplify.d.ts | 2 +- source/delimiter-case.d.ts | 10 +- source/delimiter-cased-properties-deep.d.ts | 16 +- source/delimiter-cased-properties.d.ts | 8 +- source/distributed-omit.d.ts | 25 +- source/distributed-pick.d.ts | 27 +- source/empty-object.d.ts | 13 +- source/entries.d.ts | 6 +- source/entry.d.ts | 6 +- source/exact.d.ts | 13 +- source/except.d.ts | 14 +- source/exclude-rest-element.d.ts | 40 + source/exclude-strict.d.ts | 4 + source/exclusify-union.d.ts | 147 ++ source/extract-rest-element.d.ts | 30 + source/extract-strict.d.ts | 6 +- source/find-global-type.d.ts | 12 +- source/fixed-length-array.d.ts | 92 +- source/get.d.ts | 61 +- source/global-this.d.ts | 3 +- source/greater-than-or-equal.d.ts | 14 +- source/greater-than.d.ts | 6 +- source/has-optional-keys.d.ts | 4 +- source/has-readonly-keys.d.ts | 4 +- source/has-required-keys.d.ts | 20 +- source/has-writable-keys.d.ts | 4 +- source/if.d.ts | 41 +- source/int-closed-range.d.ts | 26 +- source/int-range.d.ts | 26 +- source/internal/array.d.ts | 17 +- source/internal/numeric.d.ts | 14 +- source/internal/object.d.ts | 51 +- source/internal/string.d.ts | 30 +- source/internal/tuple.d.ts | 10 +- source/invariant-of.d.ts | 45 +- source/is-any.d.ts | 4 +- source/is-integer.d.ts | 2 +- source/is-literal.d.ts | 29 +- source/is-lowercase.d.ts | 6 +- source/is-never.d.ts | 54 +- source/is-null.d.ts | 2 +- source/is-optional-key-of.d.ts | 8 +- source/is-readonly-key-of.d.ts | 8 +- source/is-required-key-of.d.ts | 8 +- source/is-unknown.d.ts | 35 +- source/is-uppercase.d.ts | 6 +- source/is-writable-key-of.d.ts | 8 +- source/iterable-element.d.ts | 6 +- source/join.d.ts | 45 +- source/json-value.d.ts | 2 +- source/jsonifiable.d.ts | 16 +- source/jsonify.d.ts | 15 +- source/kebab-case.d.ts | 6 +- source/kebab-cased-properties-deep.d.ts | 16 +- source/kebab-cased-properties.d.ts | 8 +- source/key-as-string.d.ts | 2 +- source/last-array-element.d.ts | 16 +- source/less-than-or-equal.d.ts | 6 +- source/less-than.d.ts | 6 +- source/literal-to-primitive-deep.d.ts | 49 +- source/literal-union.d.ts | 4 +- source/merge-deep.d.ts | 32 +- source/merge-exclusive.d.ts | 17 +- source/merge.d.ts | 9 +- source/multidimensional-array.d.ts | 22 +- source/multidimensional-readonly-array.d.ts | 26 +- source/non-empty-object.d.ts | 1 + source/non-empty-string.d.ts | 10 +- source/non-empty-tuple.d.ts | 5 +- source/numeric.d.ts | 48 +- source/object-merge.d.ts | 194 +++ source/omit-deep.d.ts | 45 +- source/omit-index-signature.d.ts | 51 +- source/optional-keys-of.d.ts | 8 +- source/or.d.ts | 12 +- source/override-properties.d.ts | 19 +- source/package-json.d.ts | 32 + source/partial-deep.d.ts | 35 +- source/partial-on-undefined-deep.d.ts | 6 +- source/pascal-case.d.ts | 4 +- source/pascal-cased-properties-deep.d.ts | 12 +- source/pascal-cased-properties.d.ts | 8 +- source/paths.d.ts | 14 +- source/pick-deep.d.ts | 4 +- source/pick-index-signature.d.ts | 2 +- source/promisable.d.ts | 4 +- source/readonly-deep.d.ts | 57 +- source/readonly-keys-of.d.ts | 6 +- source/readonly-tuple.d.ts | 27 +- source/remove-prefix.d.ts | 4 +- source/replace.d.ts | 6 +- source/require-all-or-none.d.ts | 4 +- source/require-at-least-one.d.ts | 2 +- source/require-exactly-one.d.ts | 2 +- source/require-one-or-none.d.ts | 6 +- source/required-deep.d.ts | 6 +- source/required-keys-of.d.ts | 14 +- source/schema.d.ts | 17 +- source/set-field-type.d.ts | 4 +- source/set-non-nullable-deep.d.ts | 2 +- source/set-non-nullable.d.ts | 2 +- source/set-optional.d.ts | 2 +- source/set-parameter-type.d.ts | 22 +- source/set-readonly.d.ts | 2 +- source/set-required-deep.d.ts | 16 +- source/set-required.d.ts | 2 +- source/set-return-type.d.ts | 6 +- source/shared-union-fields-deep.d.ts | 12 +- source/shared-union-fields.d.ts | 14 +- source/simplify-deep.d.ts | 7 +- source/simplify.d.ts | 5 +- source/single-key-object.d.ts | 12 +- source/snake-case.d.ts | 6 +- source/snake-cased-properties-deep.d.ts | 14 +- source/snake-cased-properties.d.ts | 8 +- source/split-on-rest-element.d.ts | 106 ++ source/split.d.ts | 8 +- source/spread.d.ts | 8 +- source/string-repeat.d.ts | 4 +- source/string-slice.d.ts | 12 +- source/stringified.d.ts | 4 +- source/structured-cloneable.d.ts | 27 +- source/subtract.d.ts | 16 +- source/sum.d.ts | 14 +- source/tagged-union.d.ts | 4 +- source/tagged.d.ts | 28 +- source/trim.d.ts | 2 +- source/tsconfig-json.d.ts | 35 +- source/tuple-of.d.ts | 8 +- source/tuple-to-object.d.ts | 14 +- source/tuple-to-union.d.ts | 7 +- source/undefined-on-partial-deep.d.ts | 9 +- source/union-to-intersection.d.ts | 32 +- source/union-to-tuple.d.ts | 6 +- source/unknown-record.d.ts | 12 +- source/unwrap-partial.d.ts | 33 + source/value-of.d.ts | 32 +- source/words.d.ts | 6 +- source/writable-deep.d.ts | 2 +- source/writable-keys-of.d.ts | 4 +- source/writable.d.ts | 2 + source/xor.d.ts | 83 + test-d/and.ts | 18 +- test-d/array-element.ts | 51 + test-d/array-reverse.ts | 299 ++++ test-d/array-slice.ts | 37 + test-d/camel-case.ts | 8 + test-d/conditional-pick-deep.ts | 35 +- test-d/exclude-rest-element.ts | 64 + test-d/exclusify-union.ts | 85 + test-d/extract-rest-element.ts | 58 + test-d/fixed-length-array.ts | 77 +- test-d/greater-than-or-equal.ts | 1 + test-d/internal/normalized-keys.ts | 23 + test-d/is-equal.ts | 8 + test-d/jsonify.ts | 7 +- test-d/less-than.ts | 1 + test-d/merge-deep.ts | 16 + test-d/object-merge.ts | 292 ++++ test-d/or.ts | 23 +- test-d/override-properties.ts | 4 +- test-d/package-json.ts | 12 + test-d/partial-deep.ts | 9 +- test-d/required-deep.ts | 7 +- test-d/split-on-rest-element.ts | 66 + test-d/string-slice.ts | 37 + test-d/union-to-intersection.ts | 6 +- test-d/unwrap-partial.ts | 85 + test-d/xor.ts | 32 + xo.config.js | 60 +- 209 files changed, 7654 insertions(+), 1554 deletions(-) create mode 100644 .github/workflows/claude-code-review.yml create mode 100644 .github/workflows/claude.yml create mode 100644 CLAUDE.md create mode 100644 lint-processors/fixtures/eslint.config.js create mode 100644 lint-processors/jsdoc-codeblocks.js create mode 100644 lint-processors/jsdoc-codeblocks.test.js create mode 100644 lint-rules/import-path.test.js create mode 100644 lint-rules/source-files-extension.test.js create mode 100644 lint-rules/test-utils.js create mode 100644 lint-rules/validate-jsdoc-codeblocks.js create mode 100644 lint-rules/validate-jsdoc-codeblocks.test.js create mode 100644 source/array-element.d.ts create mode 100644 source/array-reverse.d.ts create mode 100644 source/exclude-rest-element.d.ts create mode 100644 source/exclusify-union.d.ts create mode 100644 source/extract-rest-element.d.ts create mode 100644 source/object-merge.d.ts create mode 100644 source/split-on-rest-element.d.ts create mode 100644 source/unwrap-partial.d.ts create mode 100644 source/xor.d.ts create mode 100644 test-d/array-element.ts create mode 100644 test-d/array-reverse.ts create mode 100644 test-d/exclude-rest-element.ts create mode 100644 test-d/exclusify-union.ts create mode 100644 test-d/extract-rest-element.ts create mode 100644 test-d/internal/normalized-keys.ts create mode 100644 test-d/object-merge.ts create mode 100644 test-d/split-on-rest-element.ts create mode 100644 test-d/unwrap-partial.ts create mode 100644 test-d/xor.ts diff --git a/.github/contributing.md b/.github/contributing.md index 96b18b3b9..1dcd09f5f 100644 --- a/.github/contributing.md +++ b/.github/contributing.md @@ -23,6 +23,6 @@ - If you add any internal helper types, they should still be properly documented and tested. - Add the type to the readme. - Make sure the file in the `source` directory uses a `.d.ts` extension and not `.ts`. -- **Use AI (like ChatGPT) to catch type bugs, improve docs, spot typos, validate examples, and suggest more tests.** Include all relevant code (type, tests, helpers, etc.) in the prompt, and also provide a couple of existing types as examples of how it's done. Try this prompt: “Review this TypeScript type for correctness, edge cases, naming, docs, and test coverage. Suggest improvements and realistic succint examples.” +- **Use AI (like ChatGPT) to catch type bugs, improve docs, spot typos, validate examples, and suggest more tests.** Include all relevant code (type, tests, helpers, etc.) in the prompt, and also provide a couple of existing types as examples of how it's done. Try this prompt: “Review this TypeScript type for correctness, edge cases, naming, docs, and test coverage. Suggest improvements and realistic succinct examples.” - Run `$ npm test` before submitting and make sure it passes. - Name the pull request ```Add `TypeName` type```. diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 000000000..4e98f792e --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,45 @@ +name: Claude Code Review +on: + pull_request: + types: [opened, synchronize] +jobs: + claude-review: + runs-on: ubuntu-latest + # Only run if the PR is from the same repository (not a fork), because forks are not supported yet: https://github.com/anthropics/claude-code-action/issues/339 + if: github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: read + pull-requests: write + issues: read + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 1 + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + - Be succinct + - Only comment about things that needs to be fixed/improved + - Review thoroughly + + Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. + + Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. + + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://docs.claude.com/en/docs/claude-code/sdk#command-line for available options + claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..45c6704c7 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,36 @@ +name: Claude Code +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + additional_permissions: | + actions: read diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2004529c4..5951f1037 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -42,14 +42,4 @@ jobs: node-version: 24 - run: npm install - run: npm install typescript@${{ matrix.typescript-version }} - - run: npx tsc - test-export: - name: Test Module Export - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 - with: - node-version: 24 - - run: npm install rollup rollup-plugin-dts - - run: npx rollup index.d.ts -p rollup-plugin-dts -d temp + - run: NODE_OPTIONS="--max-old-space-size=6144" npx tsc diff --git a/.github/workflows/ts-canary.yml b/.github/workflows/ts-canary.yml index 2313e852c..7073ec4c7 100644 --- a/.github/workflows/ts-canary.yml +++ b/.github/workflows/ts-canary.yml @@ -23,4 +23,4 @@ jobs: - run: npm install typescript@${{ matrix.typescript-version }} - name: show installed typescript version run: npm list typescript --depth=0 - - run: npx tsc + - run: NODE_OPTIONS="--max-old-space-size=6144" npx tsc diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..3ce5c3529 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,115 @@ +# type-fest + +TypeScript utility types library. Pure type-level programming—no runtime code. + +**Read `.github/contributing.md` for complete guidelines.** + +## Commands + +```bash +npm test # MUST pass before committing (runs all below) +npm run test:tsc # TypeScript compiler +npm run test:tsd # Type tests (tsd) +npm run test:xo # Linter +``` + +## File Structure + +``` +source/type-name.d.ts → Type definition (.d.ts REQUIRED) +test-d/type-name.ts → Tests (tsd) +index.d.ts → Export (MUST add, lint enforces) +readme.md → API docs (add to category) +source/internal/ → Shared helpers (not exported) +``` + +## Code Patterns + +```ts +// ✅ CORRECT +import type {IsNever} from './is-never.d.ts'; // Include .d.ts +export type MyType = ... // Descriptive names (not T/U) +export {}; // REQUIRED at end + +// ❌ WRONG +import type {IsNever} from './is-never'; // Missing .d.ts +export type MyType = ...; // Single-letter + +/** +Creates a tuple type of specified length with elements of specified type. + +Use-cases: +- Define fixed-length arrays with specific types + +@example +``` +import type {TupleOf} from 'type-fest'; +type RGB = TupleOf<3, number>; //=> [number, number, number] +``` + +@category Array +*/ +export type TupleOf = ...; +export {}; +``` + +Type params: `Value`, `Target`, `Item`, `Length`, `Element`, `Key` (not `T`, `U`, `V`, `K`) +Docs: No `*` prefix. First line → readme. Include use-cases, examples, `@category`. + +## Testing + +**ALWAYS test edge cases** (`any`, `never`, `unknown`). + +```ts +expectType('' as MyType<'foo'>); // Basic +expectType('' as MyType); // any/never/unknown +expectNotAssignable>({wrong: true}); // Negative +expectError>(); // Should error +``` + +Study: `source/tuple-of.d.ts`, `is-equal.d.ts`, `literal-union.d.ts`, `internal/*.d.ts` + +## Workflow + +1. Research - Study `source/` similar types + online research +2. Define - `source/type-name.d.ts` with docs +3. Test - `test-d/type-name.ts` with edge cases +4. Export - Add to `index.d.ts` + readme.md +5. Verify - `npm test` must pass +6. Commit - ``Add `TypeName` type`` or `` `TypeName`: Fix description`` + +## Critical Rules + +1. Import paths MUST include `.d.ts` extension +2. Files MUST end with `export {};` +3. Types MUST be exported from `index.d.ts` +4. Type params MUST use descriptive names (not `T`/`U`) +5. MUST test `any`, `never`, `unknown` edge cases +6. First doc line MUST be concise (goes in readme) +7. Use realistic examples with `//=>` comments +8. Include `@category` tags +9. Fix lint issues, don't disable rules + +## Type Programming + +- Conditionals: `T extends U ? X : Y` +- Recursion: `type Loop = ... Loop<...> ...` +- Extract: `infer Item` +- Distribute: `T extends any ? ... : never` +- Count via tuple `['length']` +- Union distribution is tricky—test it + +## Philosophy + +- Correctness > cleverness +- Real problems only +- Docs teach how, not what +- Edge cases mandatory +- One concept per PR +- Descriptive names > brevity +- No unrelated changes + +## Troubleshooting + +- Tests fail? Check `.d.ts` imports, `export {};`, edge cases (`any`, `never`, `unknown`) +- Lint errors? Add to `index.d.ts`, fix issues (don't disable rules) diff --git a/index.d.ts b/index.d.ts index b00eea7da..8a9926d8d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -22,6 +22,7 @@ export type {TaggedUnion} from './source/tagged-union.d.ts'; export type {Writable} from './source/writable.d.ts'; export type {WritableDeep} from './source/writable-deep.d.ts'; export type {Merge} from './source/merge.d.ts'; +export type {ObjectMerge} from './source/object-merge.d.ts'; export type {MergeDeep, MergeDeepOptions} from './source/merge-deep.d.ts'; export type {MergeExclusive} from './source/merge-exclusive.d.ts'; export type {RequireAtLeastOne} from './source/require-at-least-one.d.ts'; @@ -32,6 +33,7 @@ export type {SingleKeyObject} from './source/single-key-object.d.ts'; export type {OmitIndexSignature} from './source/omit-index-signature.d.ts'; export type {PickIndexSignature} from './source/pick-index-signature.d.ts'; export type {PartialDeep, PartialDeepOptions} from './source/partial-deep.d.ts'; +export type {UnwrapPartial} from './source/unwrap-partial.d.ts'; export type {RequiredDeep} from './source/required-deep.d.ts'; export type {PickDeep} from './source/pick-deep.d.ts'; export type {OmitDeep} from './source/omit-deep.d.ts'; @@ -111,6 +113,9 @@ export type {WritableKeysOf} from './source/writable-keys-of.d.ts'; export type {IsWritableKeyOf} from './source/is-writable-key-of.d.ts'; export type {HasWritableKeys} from './source/has-writable-keys.d.ts'; export type {Spread} from './source/spread.d.ts'; +export type {SplitOnRestElement} from './source/split-on-rest-element.d.ts'; +export type {ExtractRestElement} from './source/extract-rest-element.d.ts'; +export type {ExcludeRestElement} from './source/exclude-rest-element.d.ts'; export type {IsInteger} from './source/is-integer.d.ts'; export type {IsFloat} from './source/is-float.d.ts'; export type {TupleToObject} from './source/tuple-to-object.d.ts'; @@ -138,6 +143,7 @@ export type {ArrayValues} from './source/array-values.d.ts'; export type {ArraySlice} from './source/array-slice.d.ts'; export type {ArraySplice} from './source/array-splice.d.ts'; export type {ArrayTail} from './source/array-tail.d.ts'; +export type {ArrayElement} from './source/array-element.d.ts'; export type {SetFieldType, SetFieldTypeOptions} from './source/set-field-type.d.ts'; export type {Paths, PathsOptions} from './source/paths.d.ts'; export type {AllUnionFields} from './source/all-union-fields.d.ts'; @@ -148,6 +154,7 @@ export type {IfNull} from './source/if-null.d.ts'; export type {IsUndefined} from './source/is-undefined.d.ts'; export type {And} from './source/and.d.ts'; export type {Or} from './source/or.d.ts'; +export type {Xor} from './source/xor.d.ts'; export type {AllExtend, AllExtendOptions} from './source/all-extend.d.ts'; export type {NonEmptyTuple} from './source/non-empty-tuple.d.ts'; export type {FindGlobalInstanceType, FindGlobalType} from './source/find-global-type.d.ts'; @@ -158,6 +165,8 @@ export type {IsUppercase} from './source/is-uppercase.d.ts'; export type {IsOptional} from './source/is-optional.d.ts'; export type {IsNullable} from './source/is-nullable.d.ts'; export type {TupleOf} from './source/tuple-of.d.ts'; +export type {ExclusifyUnion} from './source/exclusify-union.d.ts'; +export type {ArrayReverse} from './source/array-reverse.d.ts'; // Template literal types export type {CamelCase, CamelCaseOptions} from './source/camel-case.d.ts'; @@ -198,3 +207,5 @@ export type {TsConfigJson} from './source/tsconfig-json.d.ts'; export type {ExtendsStrict} from './source/extends-strict.d.ts'; export type {ExtractStrict} from './source/extract-strict.d.ts'; export type {ExcludeStrict} from './source/exclude-strict.d.ts'; + +export {}; diff --git a/lint-processors/fixtures/eslint.config.js b/lint-processors/fixtures/eslint.config.js new file mode 100644 index 000000000..886c42ee6 --- /dev/null +++ b/lint-processors/fixtures/eslint.config.js @@ -0,0 +1,62 @@ +import tseslint from 'typescript-eslint'; +import {defineConfig} from 'eslint/config'; +import {jsdocCodeblocksProcessor} from '../jsdoc-codeblocks.js'; + +const errorEndingAtFirstColumnRule = { + create(context) { + return { + 'TSTypeAliasDeclaration Literal'(node) { + if (node.value !== 'error_ending_at_first_column') { + return; + } + + context.report({ + loc: { + start: { + line: node.loc.start.line, + column: 0, + }, + end: { + line: node.loc.start.line + 1, + column: 0, + }, + }, + message: 'Error ending at first column', + }); + }, + }; + }, +}; + +const config = defineConfig( + tseslint.configs.recommended, + tseslint.configs.stylistic, + { + rules: { + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/consistent-type-definitions': [ + 'error', + 'type', + ], + 'test/error-ending-at-first-column': 'error', + }, + }, + { + plugins: { + test: { + processors: { + 'jsdoc-codeblocks': jsdocCodeblocksProcessor, + }, + rules: { + 'error-ending-at-first-column': errorEndingAtFirstColumnRule, + }, + }, + }, + }, + { + files: ['**/*.d.ts'], + processor: 'test/jsdoc-codeblocks', + }, +); + +export default config; diff --git a/lint-processors/jsdoc-codeblocks.js b/lint-processors/jsdoc-codeblocks.js new file mode 100644 index 000000000..90c3259dc --- /dev/null +++ b/lint-processors/jsdoc-codeblocks.js @@ -0,0 +1,172 @@ +// @ts-check +import tsParser from '@typescript-eslint/parser'; + +/** +@import {Linter} from 'eslint'; +*/ + +const CODEBLOCK_REGEX = /(?(?^[ \t]*)```(?:ts|typescript)?\n)(?[\s\S]*?)\n\s*```/gm; +/** +@typedef {{lineOffset: number, characterOffset: number, indent: string, unindentedText: string}} CodeblockData +@type {Map} +*/ +const codeblockDataPerFile = new Map(); + +/** +@param {string} text +@param {number} index +@param {string} indent +@returns {number} +*/ +function indentsUptoIndex(text, index, indent) { + let i = 0; + let indents = 0; + + for (const line of text.split('\n')) { + if (i > index) { + break; + } + + if (line === '') { + i += 1; // +1 for the newline + continue; + } + + i += line.length + 1; // +1 for the newline + i -= indent.length; // Because `text` is unindented but `index` corresponds to dedented text + indents += indent.length; + } + + return indents; +} + +export const jsdocCodeblocksProcessor = { + supportsAutofix: true, + + /** + @param {string} text + @param {string} filename + @returns {(string | Linter.ProcessorFile)[]} + */ + preprocess(text, filename) { + const ast = tsParser.parse(text); + + const jsdocComments = ast.comments.filter( + comment => comment.type === 'Block' && comment.value.startsWith('*'), + ); + + /** @type {(string | Linter.ProcessorFile)[]} */ + const files = [text]; // First entry is for the entire file + /** @type {CodeblockData[]} */ + const allCodeblocksData = []; + + // Loop over all JSDoc comments in the file + for (const comment of jsdocComments) { + // Loop over all codeblocks in the JSDoc comment + for (const match of comment.value.matchAll(CODEBLOCK_REGEX)) { + const {code, openingFence, indent} = match.groups ?? {}; + + // Skip empty code blocks + if (!code || !openingFence || indent === undefined) { + continue; + } + + const codeLines = code.split('\n'); + const indentSize = indent.length; + + // Skip comments that are not consistently indented + if (!codeLines.every(line => line === '' || line.startsWith(indent))) { + continue; + } + + const dedentedCode = codeLines + .map(line => line.slice(indentSize)) + .join('\n'); + + files.push({ + text: dedentedCode, + filename: `${files.length}.ts`, // Final filename example: `/path/to/type-fest/source/and.d.ts/1_1.ts` + }); + + const linesBeforeMatch = comment.value.slice(0, match.index).split('\n').length - 1; + allCodeblocksData.push({ + lineOffset: comment.loc.start.line + linesBeforeMatch, + characterOffset: comment.range[0] + match.index + openingFence.length + 2, // +2 because `comment.value` doesn't include the starting `/*` + indent, + unindentedText: code, + }); + } + } + + codeblockDataPerFile.set(filename, allCodeblocksData); + + return files; + }, + + /** + @param {import('eslint').Linter.LintMessage[][]} messages + @param {string} filename + @returns {import('eslint').Linter.LintMessage[]} + */ + postprocess(messages, filename) { + const codeblocks = codeblockDataPerFile.get(filename) || []; + codeblockDataPerFile.delete(filename); + + const normalizedMessages = [...(messages[0] ?? [])]; // First entry contains errors for the entire file, and it doesn't need any adjustments + + for (const [index, codeblockMessages] of messages.slice(1).entries()) { + const codeblockData = codeblocks[index]; + + if (!codeblockData) { + // This should ideally never happen + continue; + } + + const {lineOffset, characterOffset, indent, unindentedText} = codeblockData; + + for (const message of codeblockMessages) { + message.line += lineOffset; + message.column += indent.length; + + if (typeof message.endColumn === 'number' && message.endColumn > 1) { + // An `endColumn` of `1` indicates the error actually ended on the previous line since it's exclusive. + // So, adding `indent.length` in this case would incorrectly move the error marker into the indentation. + // Therefore, the indentation length is only added when `endColumn` is greater than `1`. + message.endColumn += indent.length; + } + + if (typeof message.endLine === 'number') { + message.endLine += lineOffset; + } + + if (message.fix) { + message.fix.text = message.fix.text.split('\n').join(`\n${indent}`); + + const indentsBeforeFixStart = indentsUptoIndex(unindentedText, message.fix.range[0], indent); + const indentsBeforeFixEnd = indentsUptoIndex(unindentedText, message.fix.range[1] - 1, indent); // -1 because range end is exclusive + + message.fix.range = [ + message.fix.range[0] + characterOffset + indentsBeforeFixStart, + message.fix.range[1] + characterOffset + indentsBeforeFixEnd, + ]; + } + + for (const {fix} of (message.suggestions ?? [])) { + fix.text = fix.text.split('\n').join(`\n${indent}`); + + const indentsBeforeFixStart = indentsUptoIndex(unindentedText, fix.range[0], indent); + const indentsBeforeFixEnd = indentsUptoIndex(unindentedText, fix.range[1] - 1, indent); // -1 because range end is exclusive + + fix.range = [ + fix.range[0] + characterOffset + indentsBeforeFixStart, + fix.range[1] + characterOffset + indentsBeforeFixEnd, + ]; + } + + normalizedMessages.push(message); + } + } + + return normalizedMessages; + }, +}; diff --git a/lint-processors/jsdoc-codeblocks.test.js b/lint-processors/jsdoc-codeblocks.test.js new file mode 100644 index 000000000..338c6a884 --- /dev/null +++ b/lint-processors/jsdoc-codeblocks.test.js @@ -0,0 +1,1481 @@ +import {describe, test} from 'node:test'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {ESLint} from 'eslint'; +import {code1, code2, dedenter, errorAt, exportType, exportTypeAndOption, fence, jsdoc} from '../lint-rules/test-utils.js'; + +const root = path.join(import.meta.dirname, 'fixtures'); + +try { + await fs.access(path.join(root, 'eslint.config.js')); +} catch { + throw new Error('\'eslint.config.js\' is missing in \'lint-processors/fixtures\' directory.'); +} + +const valid = [ + { + name: 'No JSDoc', + code: exportTypeAndOption(''), + }, + { + name: 'JSDoc without code block', + code: exportType(jsdoc('No codeblock here')), + }, + { + name: 'Valid code block', + code: exportTypeAndOption(jsdoc(fence(code1))), + }, + { + name: 'With text before and after', + code: exportTypeAndOption(jsdoc('Some description.', fence(code1), '@category Test')), + }, + { + name: 'With line breaks before and after', + code: exportTypeAndOption( + jsdoc('Some description.\n', 'Note: Some note.\n', fence(code1, 'ts'), '\n@category Test'), + ), + }, + { + name: 'With @example tag', + code: exportTypeAndOption(jsdoc('@example', fence(code1))), + }, + { + name: 'With ts language specifier', + code: exportTypeAndOption(jsdoc(fence(code1, 'ts'))), + }, + { + name: 'With typescript language specifier', + code: exportTypeAndOption(jsdoc(fence(code1, 'typescript'))), + }, + { + name: 'Multiple code blocks', + code: exportTypeAndOption( + jsdoc('@example', fence(code1, 'ts'), '\nSome text in between.\n', '@example', fence(code2)), + ), + }, + { + name: 'Multiple exports and multiple properties', + code: exportTypeAndOption(jsdoc(fence(code1)), jsdoc(fence(code2))), + }, + { + name: 'Indented code blocks', + code: exportTypeAndOption(jsdoc( + 'Note:', + dedenter` + 1. First point + \`\`\`ts + import type {Subtract} from 'type-fest'; + type A = Subtract<1, 2>; + \`\`\` + 2. Second point + \`\`\`ts + import type {Sum} from 'type-fest'; + type A = Sum<1, 2>; + \`\`\` + `, + )), + }, + { + name: 'Ignore codeblocks with inconsistent indentation', + code: exportTypeAndOption(jsdoc( + 'Some description.', + dedenter` + Note: + @example + \`\`\`ts + const foo: string = '1'; + + const bar: number = 1; + \`\`\` + `, + )), + }, +]; + +const invalid = [ + { + name: 'With text before and after', + code: dedenter` + /** + Some description. + \`\`\`ts + const foo: Array = []; + \`\`\` + @category Test + */ + export type T0 = string; + + export type TOptions = { + /** + Some description. + \`\`\`ts + const foo: Array = []; + \`\`\` + @category Test + */ + p0: string; + }; + `, + output: dedenter` + /** + Some description. + \`\`\`ts + const foo: string[] = []; + \`\`\` + @category Test + */ + export type T0 = string; + + export type TOptions = { + /** + Some description. + \`\`\`ts + const foo: string[] = []; + \`\`\` + @category Test + */ + p0: string; + }; + `, + errors: [ + errorAt({ + ruleId: '@typescript-eslint/array-type', + line: 4, + textBeforeStart: 'const foo: ', + target: 'Array', + }), + errorAt({ + ruleId: '@typescript-eslint/array-type', + line: 14, + textBeforeStart: '\tconst foo: ', + target: 'Array', + }), + ], + }, + + { + name: 'With line breaks before and after', + code: dedenter` + /** + Some description. + + Note: Some note. + + \`\`\`ts + const foo: number = 1; + \`\`\` + + @category Test + */ + export type T0 = string; + + export type TOptions = { + /** + Some description. + + Note: Some note. + + \`\`\`ts + const foo: number = 1; + \`\`\` + + @category Test + */ + p0: string; + }; + `, + output: dedenter` + /** + Some description. + + Note: Some note. + + \`\`\`ts + const foo = 1; + \`\`\` + + @category Test + */ + export type T0 = string; + + export type TOptions = { + /** + Some description. + + Note: Some note. + + \`\`\`ts + const foo = 1; + \`\`\` + + @category Test + */ + p0: string; + }; + `, + errors: [ + errorAt({ + ruleId: '@typescript-eslint/no-inferrable-types', + line: 7, + textBeforeStart: 'const ', + target: 'foo: number = 1', + }), + errorAt({ + ruleId: '@typescript-eslint/no-inferrable-types', + line: 21, + textBeforeStart: '\tconst ', + target: 'foo: number = 1', + }), + ], + }, + + { + name: 'With @example tag', + code: dedenter` + /** + @example + \`\`\`ts + interface Foo { + a: string; + b: number; + } + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + @example + \`\`\`ts + interface Foo { + a: string; + b: number; + } + \`\`\` + */ + p0: string; + }; + `, + output: dedenter` + /** + @example + \`\`\`ts + type Foo = { + a: string; + b: number; + } + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + @example + \`\`\`ts + type Foo = { + a: string; + b: number; + } + \`\`\` + */ + p0: string; + }; + `, + errors: [ + errorAt({ + ruleId: '@typescript-eslint/consistent-type-definitions', + line: 4, + textBeforeStart: 'interface ', + target: 'Foo', + }), + errorAt({ + ruleId: '@typescript-eslint/consistent-type-definitions', + line: 16, + textBeforeStart: '\tinterface ', + target: 'Foo', + }), + ], + }, + + { + name: 'With language specifiers', + code: dedenter` + /** + \`\`\`ts + const foo: Array = []; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + \`\`\`ts + const foo: Array = []; + \`\`\` + */ + p0: string; + }; + `, + output: dedenter` + /** + \`\`\`ts + const foo: string[] = []; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + \`\`\`ts + const foo: string[] = []; + \`\`\` + */ + p0: string; + }; + `, + errors: [ + errorAt({ + ruleId: '@typescript-eslint/array-type', + line: 3, + textBeforeStart: 'const foo: ', + target: 'Array', + }), + errorAt({ + ruleId: '@typescript-eslint/array-type', + line: 11, + textBeforeStart: '\tconst foo: ', + target: 'Array', + }), + ], + }, + + { + name: 'With typescript language specifiers', + code: dedenter` + /** + \`\`\`typescript + const foo: Array = []; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + \`\`\`typescript + const foo: Array = []; + \`\`\` + */ + p0: string; + }; + `, + output: dedenter` + /** + \`\`\`typescript + const foo: string[] = []; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + \`\`\`typescript + const foo: string[] = []; + \`\`\` + */ + p0: string; + }; + `, + errors: [ + errorAt({ + ruleId: '@typescript-eslint/array-type', + line: 3, + textBeforeStart: 'const foo: ', + target: 'Array', + }), + errorAt({ + ruleId: '@typescript-eslint/array-type', + line: 11, + textBeforeStart: '\tconst foo: ', + target: 'Array', + }), + ], + }, + + { + name: 'Multiple code blocks', + code: dedenter` + /** + @example + \`\`\`ts + const foo: Array = []; + \`\`\` + + Some text in between. + + @example + \`\`\`ts + const bar: number = 1; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + @example + \`\`\`ts + const foo: Array = []; + \`\`\` + + Some text in between. + + @example + \`\`\`ts + const bar: number = 1; + \`\`\` + */ + p0: string; + }; + `, + output: dedenter` + /** + @example + \`\`\`ts + const foo: string[] = []; + \`\`\` + + Some text in between. + + @example + \`\`\`ts + const bar = 1; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + @example + \`\`\`ts + const foo: string[] = []; + \`\`\` + + Some text in between. + + @example + \`\`\`ts + const bar = 1; + \`\`\` + */ + p0: string; + }; + `, + errors: [ + errorAt({ + ruleId: '@typescript-eslint/array-type', + line: 4, + textBeforeStart: 'const foo: ', + target: 'Array', + }), + errorAt({ + ruleId: '@typescript-eslint/no-inferrable-types', + line: 11, + textBeforeStart: 'const ', + target: 'bar: number = 1', + }), + errorAt({ + ruleId: '@typescript-eslint/array-type', + line: 20, + textBeforeStart: '\tconst foo: ', + target: 'Array', + }), + errorAt({ + ruleId: '@typescript-eslint/no-inferrable-types', + line: 27, + textBeforeStart: '\tconst ', + target: 'bar: number = 1', + }), + ], + }, + + { + name: 'Multiple exports and multiple properties', + code: dedenter` + /** + \`\`\`ts + function foo(example: {(): number}): number { + return example(); + } + \`\`\` + */ + export type T0 = string; + + /** + \`\`\`ts + type Foo = { + [key: string]: unknown; + }; + \`\`\` + */ + export type T1 = string; + + export type T0Options = { + /** + \`\`\`ts + function foo(example: {(): number}): number { + return example(); + } + \`\`\` + */ + p0: string; + + /** + \`\`\`ts + type Foo = { + [key: string]: unknown; + }; + \`\`\` + */ + p1: string; + }; + + export type T1Options = { + /** + \`\`\`ts + const foo: Map = new Map(); + \`\`\` + */ + p0: string; + }; + `, + output: dedenter` + /** + \`\`\`ts + function foo(example: () => number): number { + return example(); + } + \`\`\` + */ + export type T0 = string; + + /** + \`\`\`ts + type Foo = Record; + \`\`\` + */ + export type T1 = string; + + export type T0Options = { + /** + \`\`\`ts + function foo(example: () => number): number { + return example(); + } + \`\`\` + */ + p0: string; + + /** + \`\`\`ts + type Foo = Record; + \`\`\` + */ + p1: string; + }; + + export type T1Options = { + /** + \`\`\`ts + const foo = new Map(); + \`\`\` + */ + p0: string; + }; + `, + errors: [ + errorAt({ + ruleId: '@typescript-eslint/prefer-function-type', + line: 3, + textBeforeStart: 'function foo(example: {', + target: '(): number', + }), + errorAt({ + ruleId: '@typescript-eslint/consistent-indexed-object-style', + line: 12, + textBeforeStart: 'type Foo = ', + endLine: 14, + textBeforeEnd: '}', + }), + errorAt({ + ruleId: '@typescript-eslint/prefer-function-type', + line: 22, + textBeforeStart: '\tfunction foo(example: {', + target: '(): number', + }), + errorAt({ + ruleId: '@typescript-eslint/consistent-indexed-object-style', + line: 31, + textBeforeStart: '\ttype Foo = ', + endLine: 33, + textBeforeEnd: '\t}', + }), + errorAt({ + ruleId: '@typescript-eslint/consistent-generic-constructors', + line: 42, + textBeforeStart: '\tconst ', + target: 'foo: Map = new Map()', + }), + ], + }, + + { + name: 'Indented code blocks', + code: dedenter` + /** + Note: + 1. First point + \`\`\`ts + type Foo = { + [x: string]: unknown; + }; + \`\`\` + 2. Second point + \`\`\`ts + type Bar = { + (arg: string): number; + }; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + Note: + 1. First point + \`\`\`ts + type Foo = { + [x: string]: unknown; + }; + \`\`\` + 2. Second point + \`\`\`ts + type Bar = { + (arg: string): number; + }; + \`\`\` + */ + p0: string; + }; + `, + output: dedenter` + /** + Note: + 1. First point + \`\`\`ts + type Foo = Record; + \`\`\` + 2. Second point + \`\`\`ts + type Bar = (arg: string) => number; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + Note: + 1. First point + \`\`\`ts + type Foo = Record; + \`\`\` + 2. Second point + \`\`\`ts + type Bar = (arg: string) => number; + \`\`\` + */ + p0: string; + }; + `, + errors: [ + errorAt({ + ruleId: '@typescript-eslint/consistent-indexed-object-style', + line: 5, + textBeforeStart: '\ttype Foo = ', + endLine: 7, + textBeforeEnd: '\t}', + }), + errorAt({ + ruleId: '@typescript-eslint/prefer-function-type', + line: 12, + textBeforeStart: '\t\t', + target: '(arg: string): number;', + }), + errorAt({ + ruleId: '@typescript-eslint/consistent-indexed-object-style', + line: 23, + textBeforeStart: '\t\ttype Foo = ', + endLine: 25, + textBeforeEnd: '\t\t}', + }), + errorAt({ + ruleId: '@typescript-eslint/prefer-function-type', + line: 30, + textBeforeStart: '\t\t\t', + target: '(arg: string): number;', + }), + ], + }, + + { + name: 'Error and fix starting at the first character of the codeblock', + code: dedenter` + /** + Some description. + \`\`\`ts + var foo = 1; + foo = 2; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + Some description. + \`\`\`ts + var foo = 1; + foo = 2; + \`\`\` + */ + p0: string; + }; + `, + output: dedenter` + /** + Some description. + \`\`\`ts + let foo = 1; + foo = 2; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + Some description. + \`\`\`ts + let foo = 1; + foo = 2; + \`\`\` + */ + p0: string; + }; + `, + errors: [ + errorAt({ + ruleId: 'no-var', + line: 4, + textBeforeStart: '', + target: 'var foo = 1;', + }), + errorAt({ + ruleId: 'no-var', + line: 14, + textBeforeStart: '\t', + target: 'var foo = 1;', + }), + ], + }, + + { + name: 'Error and fix in the middle of the codeblock', + code: dedenter` + /** + \`\`\`ts + const foo: string[] = []; + + const bar: Array = []; + + const baz: boolean[] = []; + \`\`\` + Some text after. + */ + export type T0 = string; + + export type TOptions = { + /** + \`\`\`ts + const foo: string[] = []; + + const bar: Array = []; + + const baz: boolean[] = []; + \`\`\` + Some text after. + */ + p0: string; + }; + `, + output: dedenter` + /** + \`\`\`ts + const foo: string[] = []; + + const bar: number[] = []; + + const baz: boolean[] = []; + \`\`\` + Some text after. + */ + export type T0 = string; + + export type TOptions = { + /** + \`\`\`ts + const foo: string[] = []; + + const bar: number[] = []; + + const baz: boolean[] = []; + \`\`\` + Some text after. + */ + p0: string; + }; + `, + errors: [ + errorAt({ + ruleId: '@typescript-eslint/array-type', + line: 5, + textBeforeStart: 'const bar: ', + target: 'Array', + }), + errorAt({ + ruleId: '@typescript-eslint/array-type', + line: 18, + textBeforeStart: '\tconst bar: ', + target: 'Array', + }), + ], + }, + + { + name: 'Error and fix ending at the last character of the codeblock', + code: dedenter` + /** + Some description. + + @example + \`\`\`ts + type Foo = {a: string} + type Bar = {[K: string]: Foo} + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + Some description. + + @example + \`\`\`ts + type Foo = {a: string} + type Bar = {[K: string]: Foo} + \`\`\` + */ + p0: string; + }; + `, + output: dedenter` + /** + Some description. + + @example + \`\`\`ts + type Foo = {a: string} + type Bar = Record + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + Some description. + + @example + \`\`\`ts + type Foo = {a: string} + type Bar = Record + \`\`\` + */ + p0: string; + }; + `, + errors: [ + errorAt({ + ruleId: '@typescript-eslint/consistent-indexed-object-style', + line: 7, + textBeforeStart: 'type Bar = ', + target: '{[K: string]: Foo}', + }), + errorAt({ + ruleId: '@typescript-eslint/consistent-indexed-object-style', + line: 19, + textBeforeStart: '\ttype Bar = ', + target: '{[K: string]: Foo}', + }), + ], + }, + + { + name: 'Error spanning multiple lines', + code: dedenter` + /** + \`\`\`ts + import type {PickIndexSignature} from 'type-fest'; + + type Foo = { + [key: string]: unknown; + }; + + type Test = PickIndexSignature; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + \`\`\`ts + import type {PickIndexSignature} from 'type-fest'; + + type Foo = { + [key: string]: unknown; + }; + + type Test = PickIndexSignature; + \`\`\` + */ + p0: string; + }; + `, + output: dedenter` + /** + \`\`\`ts + import type {PickIndexSignature} from 'type-fest'; + + type Foo = Record; + + type Test = PickIndexSignature; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + \`\`\`ts + import type {PickIndexSignature} from 'type-fest'; + + type Foo = Record; + + type Test = PickIndexSignature; + \`\`\` + */ + p0: string; + }; + `, + errors: [ + errorAt({ + ruleId: '@typescript-eslint/consistent-indexed-object-style', + line: 5, + textBeforeStart: 'type Foo = ', + endLine: 7, + textBeforeEnd: '}', + }), + errorAt({ + ruleId: '@typescript-eslint/consistent-indexed-object-style', + line: 19, + textBeforeStart: '\ttype Foo = ', + endLine: 21, + textBeforeEnd: '\t}', + }), + ], + }, + + { + name: 'Multiline fix', + code: dedenter` + /** + Some description. + Some more description. + + @example + \`\`\`ts + type Test = {( + foo: string, + bar: number + ): void}; + \`\`\` + @category Test + */ + export type T0 = string; + + export type TOptions = { + /** + Some description. + Some more description. + + @example + \`\`\`ts + type Test = {( + foo: string, + bar: number + ): void}; + \`\`\` + @category Test + */ + p0: string; + }; + `, + output: dedenter` + /** + Some description. + Some more description. + + @example + \`\`\`ts + type Test = ( + foo: string, + bar: number + ) => void; + \`\`\` + @category Test + */ + export type T0 = string; + + export type TOptions = { + /** + Some description. + Some more description. + + @example + \`\`\`ts + type Test = ( + foo: string, + bar: number + ) => void; + \`\`\` + @category Test + */ + p0: string; + }; + `, + errors: [ + errorAt({ + ruleId: '@typescript-eslint/prefer-function-type', + line: 7, + textBeforeStart: 'type Test = {', + endLine: 10, + textBeforeEnd: '): void', + }), + errorAt({ + ruleId: '@typescript-eslint/prefer-function-type', + line: 23, + textBeforeStart: '\ttype Test = {', + endLine: 26, + textBeforeEnd: '\t): void', + }), + ], + }, + + { + name: 'Multiple errors', + code: dedenter` + /** + @example + \`\`\`typescript + const foo: number = 1 + + const bar: Map = new Map() + + interface Baz { + (x: string): unknown + } + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + @example + \`\`\`typescript + const foo: number = 1 + + const bar: Map = new Map() + + interface Baz { + (x: string): unknown + } + \`\`\` + */ + p0: string; + }; + `, + output: dedenter` + /** + @example + \`\`\`typescript + const foo = 1 + + const bar = new Map() + + type Baz = (x: string) => unknown + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + @example + \`\`\`typescript + const foo = 1 + + const bar = new Map() + + type Baz = (x: string) => unknown + \`\`\` + */ + p0: string; + }; + `, + errors: [ + errorAt({ + ruleId: '@typescript-eslint/no-inferrable-types', + line: 4, + textBeforeStart: 'const ', + target: 'foo: number = 1', + }), + errorAt({ + ruleId: '@typescript-eslint/consistent-generic-constructors', + line: 6, + textBeforeStart: 'const ', + target: 'bar: Map = new Map()', + }), + errorAt({ + ruleId: '@typescript-eslint/consistent-type-definitions', + line: 8, + textBeforeStart: 'interface ', + target: 'Baz', + }), + errorAt({ + ruleId: '@typescript-eslint/prefer-function-type', + line: 9, + textBeforeStart: '\t', + target: '(x: string): unknown', + }), + errorAt({ + ruleId: '@typescript-eslint/no-inferrable-types', + line: 19, + textBeforeStart: '\tconst ', + target: 'foo: number = 1', + }), + errorAt({ + ruleId: '@typescript-eslint/consistent-generic-constructors', + line: 21, + textBeforeStart: '\tconst ', + target: 'bar: Map = new Map()', + }), + errorAt({ + ruleId: '@typescript-eslint/consistent-type-definitions', + line: 23, + textBeforeStart: '\tinterface ', + target: 'Baz', + }), + errorAt({ + ruleId: '@typescript-eslint/prefer-function-type', + line: 24, + textBeforeStart: '\t\t', + target: '(x: string): unknown', + }), + ], + }, + + { + name: 'Overlapping errors', + code: dedenter` + /** + \`\`\`ts + const foo: Array> = []; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + \`\`\`ts + const foo: Array> = []; + \`\`\` + */ + p0: string; + }; + `, + output: dedenter` + /** + \`\`\`ts + const foo: string[][] = []; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + \`\`\`ts + const foo: string[][] = []; + \`\`\` + */ + p0: string; + }; + `, + errors: [ + errorAt({ + ruleId: '@typescript-eslint/array-type', + line: 3, + textBeforeStart: 'const foo: ', + target: 'Array>', + }), + errorAt({ + ruleId: '@typescript-eslint/array-type', + line: 3, + textBeforeStart: 'const foo: Array<', + target: 'Array', + }), + errorAt({ + ruleId: '@typescript-eslint/array-type', + line: 11, + textBeforeStart: '\tconst foo: ', + target: 'Array>', + }), + errorAt({ + ruleId: '@typescript-eslint/array-type', + line: 11, + textBeforeStart: '\tconst foo: Array<', + target: 'Array', + }), + ], + }, + + { + name: 'Error reporting location different from fix location', + code: dedenter` + /** + \`\`\`ts + type Foo = { + (): void; + }; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + \`\`\`ts + type Foo = { + (): void; + }; + \`\`\` + */ + p0: string; + }; + `, + output: dedenter` + /** + \`\`\`ts + type Foo = () => void; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + \`\`\`ts + type Foo = () => void; + \`\`\` + */ + p0: string; + }; + `, + errors: [ + errorAt({ + ruleId: '@typescript-eslint/prefer-function-type', + line: 4, + textBeforeStart: '\t', + target: '(): void;', + }), + errorAt({ + ruleId: '@typescript-eslint/prefer-function-type', + line: 14, + textBeforeStart: '\t\t', + target: '(): void;', + }), + ], + }, + + { + name: 'Non fixable error', + code: dedenter` + /** + Some description. + @example + \`\`\` + type Foo = {}; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + Some description. + @example + \`\`\` + type Foo = {}; + \`\`\` + */ + p0: string; + }; + `, + output: undefined, + errors: [ + errorAt({ + ruleId: '@typescript-eslint/no-empty-object-type', + line: 5, + textBeforeStart: 'type Foo = ', + target: '{}', + }), + errorAt({ + ruleId: '@typescript-eslint/no-empty-object-type', + line: 15, + textBeforeStart: '\ttype Foo = ', + target: '{}', + }), + ], + }, + + { + name: 'Error outside JSDoc', + code: dedenter` + /** + Some description. + \`\`\` + type Foo = string; + \`\`\` + */ + export type T0 = Array; + + type Foo = String; + `, + output: dedenter` + /** + Some description. + \`\`\` + type Foo = string; + \`\`\` + */ + export type T0 = string[]; + + type Foo = string; + `, + errors: [ + errorAt({ + ruleId: '@typescript-eslint/array-type', + line: 7, + textBeforeStart: 'export type T0 = ', + target: 'Array', + }), + errorAt({ + ruleId: '@typescript-eslint/no-wrapper-object-types', + line: 9, + textBeforeStart: 'type Foo = ', + target: 'String', + }), + ], + }, + { + name: 'Error ending at first column', + code: dedenter` + /** + Some description. + + Note: + @example + \`\`\`ts + type Foo = 'error_ending_at_first_column' + type Bar = number + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + Some description. + + Note: + @example + \`\`\`ts + type Foo = 'error_ending_at_first_column' + type Bar = number + \`\`\` + */ + p0: string; + }; + `, + output: undefined, + errors: [ + errorAt({ + line: 7, + textBeforeStart: '\t', + endLine: 8, + textBeforeEnd: '', + }), + errorAt({ + line: 20, + textBeforeStart: '\t\t', + endLine: 21, + textBeforeEnd: '', + }), + ], + }, +]; + +describe('jsdoc-codeblocks processor', {concurrency: true}, () => { + const eslint = new ESLint({cwd: root}); + const eslintFixed = new ESLint({cwd: root, fix: true}); + + const testCases = [ + ...valid.map(testCase => ({...testCase, type: 'valid'})), + ...invalid.map(testCase => ({...testCase, type: 'invalid'})), + ]; + + for (const {type, name, code, output, errors = []} of testCases) { + test(`${type} - ${name}`, async t => { + const fileName = `test-${type}-${name.replaceAll(/\s+/g, '-')}.d.ts`; + const filePath = path.join(root, fileName); + await fs.writeFile(filePath, code); + t.after(async () => { + await fs.unlink(filePath); + }); + + const results = await eslint.lintFiles([fileName]); + t.assert.strictEqual(results[0].messages.length, errors.length); + + if (type === 'invalid') { + // Manual loop because `assert.partialDeepStrictEqual` isn't available in Node 20 + for (const [index, expected] of errors.entries()) { + const actual = results[0].messages[index]; + const actualSubset = Object.fromEntries(Object.keys(expected).map(key => [key, actual[key]])); + + t.assert.deepStrictEqual(actualSubset, expected); + } + + const resultsFixed = await eslintFixed.lintFiles([fileName]); + t.assert.strictEqual(resultsFixed[0].output, output); + } + }); + } +}); diff --git a/lint-rules/import-path.js b/lint-rules/import-path.js index 83c241d88..4c61b68c0 100644 --- a/lint-rules/import-path.js +++ b/lint-rules/import-path.js @@ -16,7 +16,12 @@ export const importPathRule = /** @type {const} */ ({ defaultOptions: [], create(context) { return { - ImportDeclaration(node) { + 'ImportDeclaration, ExportNamedDeclaration, ExportAllDeclaration'(node) { + // Exit if not a re-export + if (!node.source) { + return; + } + const importPath = node.source.value; // Skip if not relative path diff --git a/lint-rules/import-path.test.js b/lint-rules/import-path.test.js new file mode 100644 index 000000000..06cbe6225 --- /dev/null +++ b/lint-rules/import-path.test.js @@ -0,0 +1,132 @@ +import {createRuleTester} from './test-utils.js'; +import {importPathRule} from './import-path.js'; + +const ruleTester = createRuleTester(); + +const invalidImport = (code, output) => ({ + code, + errors: [{messageId: 'incorrectImportPath'}], + output, +}); + +ruleTester.run('import-path', importPathRule, { + valid: [ + // Already has .d.ts extension + { + code: 'import type {Foo} from "./foo.d.ts";', + }, + { + code: 'import type {Bar} from "../bar.d.ts";', + }, + { + code: 'import {Baz} from "./types/baz.d.ts";', + }, + // Non-relative imports are ignored + { + code: 'import {something} from "external-package";', + }, + { + code: 'import type {Type} from "@types/node";', + }, + // Export named declarations with .d.ts extension + { + code: 'export type {Foo} from "./foo.d.ts";', + }, + { + code: 'export {Bar} from "../bar.d.ts";', + }, + { + code: 'export type {Baz as Qux} from "./types/baz.d.ts";', + }, + // Export all declarations with .d.ts extension + { + code: 'export * from "./foo.d.ts";', + }, + { + code: 'export * from "../bar.d.ts";', + }, + { + code: 'export * as Types from "./types.d.ts";', + }, + // Non re-exports are ignored + { + code: 'export {localVar};', + }, + { + code: 'export type {LocalType};', + }, + { + code: 'export type Foo = string;', + }, + // Non-relative exports are ignored + { + code: 'export {something} from "external-package";', + }, + { + code: 'export * from "@types/node";', + }, + ], + invalid: [ + // Missing extension + invalidImport( + 'import type {Foo} from "./foo";', + 'import type {Foo} from \'./foo.d.ts\';', + ), + // Wrong extension .ts + invalidImport( + 'import type {Bar} from "../bar.ts";', + 'import type {Bar} from \'../bar.d.ts\';', + ), + // Wrong extension .js + invalidImport( + 'import type {Baz} from "./types.js";', + 'import type {Baz} from \'./types.d.ts\';', + ), + // Deep path + invalidImport( + 'import type {Deep} from "../../deep/path.tsx";', + 'import type {Deep} from \'../../deep/path.d.ts\';', + ), + // Export named declarations - missing extension + invalidImport( + 'export type {Foo} from "./foo";', + 'export type {Foo} from \'./foo.d.ts\';', + ), + invalidImport( + 'export {Bar} from "../bar";', + 'export {Bar} from \'../bar.d.ts\';', + ), + // Export named declarations - wrong extension + invalidImport( + 'export type {Baz} from "./types.ts";', + 'export type {Baz} from \'./types.d.ts\';', + ), + invalidImport( + 'export {Qux} from "./qux.js";', + 'export {Qux} from \'./qux.d.ts\';', + ), + // Export all declarations - missing extension + invalidImport( + 'export * from "./foo";', + 'export * from \'./foo.d.ts\';', + ), + invalidImport( + 'export * from "../bar";', + 'export * from \'../bar.d.ts\';', + ), + // Export all declarations - wrong extension + invalidImport( + 'export * from "./types.ts";', + 'export * from \'./types.d.ts\';', + ), + invalidImport( + 'export * as AllTypes from "../../all.js";', + 'export * as AllTypes from \'../../all.d.ts\';', + ), + // Wrong extension .d.d.ts + invalidImport( + 'import type {Foo} from "./foo.d.d.ts";', + 'import type {Foo} from \'./foo.d.ts\';', + ), + ], +}); diff --git a/lint-rules/require-export.test.js b/lint-rules/require-export.test.js index 67fe2a6db..f8786cb5d 100644 --- a/lint-rules/require-export.test.js +++ b/lint-rules/require-export.test.js @@ -1,55 +1,66 @@ -import {test} from 'node:test'; -import assert from 'node:assert/strict'; +import {createRuleTester} from './test-utils.js'; import {requireExportRule} from './require-export.js'; -const createContext = (filename, errors = []) => ({ - filename, - report: error => errors.push(error), -}); - -const runRule = (filename, node) => { - const errors = []; - const context = createContext(filename, errors); - const handlers = requireExportRule.create(context); - - for (const handler of Object.keys(handlers)) { - handlers[handler]?.(node); - } - - return errors; -}; - -test('ignores non-.d.ts files', () => { - const handlers = requireExportRule.create(createContext('/source/foo.ts')); - assert.deepEqual(handlers, {}); -}); +const ruleTester = createRuleTester(); -test('ignores files outside source', () => { - const handlers = requireExportRule.create(createContext('/test/foo.d.ts')); - assert.deepEqual(handlers, {}); -}); - -test('processes source .d.ts files', () => { - const handlers = requireExportRule.create(createContext('/source/foo.d.ts')); - assert.ok(handlers['Program:exit']); -}); - -test('passes with export {}', () => { - const errors = runRule('/source/foo.d.ts', {declaration: null, specifiers: [], source: null}); - assert.equal(errors.length, 0); -}); - -test('fails without export {}', () => { - const errors = runRule('/source/foo.d.ts', {declaration: null, specifiers: [{type: 'ExportSpecifier'}]}); - assert.equal(errors.length, 1); +const missingEmptyExport = (code, filename, output) => ({ + code, + filename, + errors: [{messageId: 'noEmptyExport'}], + output, }); -test('auto-fix adds export {}', () => { - const node = {declaration: null, specifiers: [{type: 'ExportSpecifier'}]}; - const errors = runRule('/source/foo.d.ts', node); - const fix = errors[0].fix({ - insertTextAfter: (node, text) => ({node, text}), - }); - assert.equal(fix.node, node); - assert.equal(fix.text, '\nexport {};\n'); +ruleTester.run('require-export', requireExportRule, { + valid: [ + // Has export {} + { + code: 'export {};', + filename: '/source/foo.d.ts', + }, + // Has export {} with other exports + { + code: 'export type Foo = string;\nexport {};', + filename: '/source/bar.d.ts', + }, + // Non-.d.ts files don't need export {} + { + code: 'const x = 1;', + filename: '/source/foo.ts', + }, + { + code: 'export const y = 2;', + filename: '/source/bar.js', + }, + // Files outside source directory are ignored + { + code: 'type Test = string;', + filename: '/test/foo.d.ts', + }, + { + code: 'interface ITest {}', + filename: '/lib/bar.d.ts', + }, + ], + invalid: [ + // Missing export {} in empty file + missingEmptyExport('', '/source/foo.d.ts', '\nexport {};\n'), + // Missing export {} with type declarations + missingEmptyExport( + 'export type Foo = string;', + '/source/bar.d.ts', + 'export type Foo = string;\nexport {};\n', + ), + // Missing export {} with interface + missingEmptyExport( + 'export interface IFoo {\n\tx: string;\n}', + '/source/baz.d.ts', + 'export interface IFoo {\n\tx: string;\n}\nexport {};\n', + ), + // Missing export {} with multiple exports + missingEmptyExport( + 'export type A = string;\nexport type B = number;', + '/source/multi.d.ts', + 'export type A = string;\nexport type B = number;\nexport {};\n', + ), + ], }); diff --git a/lint-rules/require-exported-types.test.js b/lint-rules/require-exported-types.test.js index c299ca35d..5ace1738d 100644 --- a/lint-rules/require-exported-types.test.js +++ b/lint-rules/require-exported-types.test.js @@ -1,469 +1,85 @@ -#!/usr/bin/env node -/** - * Test suite for the require-exported-types ESLint rule - * - * Run with: node --test lint-rules/require-exported-types.test.mjs - */ - -import {test, describe} from 'node:test'; -import assert from 'node:assert/strict'; +import {createTypeAwareRuleTester} from './test-utils.js'; import {requireExportedTypesRule} from './require-exported-types.js'; -describe('require-exported-types ESLint rule', () => { - describe('rule structure and metadata', () => { - test('has correct structure', () => { - assert.ok(requireExportedTypesRule.meta); - assert.equal(requireExportedTypesRule.meta.type, 'suggestion'); - assert.ok(requireExportedTypesRule.meta.docs); - assert.ok(requireExportedTypesRule.meta.messages); - assert.ok(requireExportedTypesRule.create); - assert.equal(typeof requireExportedTypesRule.create, 'function'); - }); - - test('has correct meta.docs', () => { - const {docs} = requireExportedTypesRule.meta; - assert.equal(docs.description, 'Enforce that exported types are also exported from index.d.ts'); - assert.equal(docs.category, 'Best Practices'); - assert.equal(docs.recommended, true); - }); - - test('has correct messages', () => { - const {messages} = requireExportedTypesRule.meta; - assert.ok(messages.missingExport.includes('Type `{{typeName}}`')); - assert.ok(messages.missingExport.includes('index.d.ts')); - assert.ok(messages.noTypeInfo.includes('TypeScript type information')); - }); - - test('has correct schema', () => { - const {schema} = requireExportedTypesRule.meta; - assert.ok(Array.isArray(schema)); - assert.equal(schema.length, 1); - assert.equal(schema[0].type, 'object'); - assert.ok(schema[0].properties.indexFile); - assert.equal(schema[0].properties.indexFile.type, 'string'); - }); - }); - - describe('file filtering', () => { - test('skips non-.d.ts files', () => { - const context = { - filename: '/project/source/foo.ts', - sourceCode: {parserServices: {}}, - options: [], - }; - const handlers = requireExportedTypesRule.create(context); - assert.deepEqual(handlers, {}); - }); - - test('skips files outside source directory', () => { - const context = { - filename: '/project/test/foo.d.ts', - sourceCode: {parserServices: {}}, - options: [], - }; - const handlers = requireExportedTypesRule.create(context); - assert.deepEqual(handlers, {}); - }); - - test('skips internal files', () => { - const context = { - filename: '/project/source/internal/foo.d.ts', - sourceCode: {parserServices: {}}, - options: [], - }; - const handlers = requireExportedTypesRule.create(context); - assert.deepEqual(handlers, {}); - }); - - test('processes source/*.d.ts files', () => { - const context = { - filename: '/project/source/foo.d.ts', - sourceCode: { - parserServices: { - program: { - getTypeChecker: () => ({getSymbolAtLocation: () => null}), - getSourceFile: () => null, - }, - esTreeNodeToTSNodeMap: new Map(), - }, - }, - options: [], - }; - const handlers = requireExportedTypesRule.create(context); - assert.ok(handlers['ExportNamedDeclaration > TSTypeAliasDeclaration']); - assert.ok(handlers['ExportNamedDeclaration > TSInterfaceDeclaration']); - }); - }); - - describe('TypeScript type information', () => { - test('requires type information', () => { - const context = { - filename: '/project/source/foo.d.ts', - sourceCode: {parserServices: {}}, // No type info - options: [], - }; - const handlers = requireExportedTypesRule.create(context); - assert.ok(handlers.Program); - assert.equal(typeof handlers.Program, 'function'); - }); - - test('reports noTypeInfo message when type info is missing', () => { - let reportedError = null; - const context = { - filename: '/project/source/foo.d.ts', - sourceCode: {parserServices: {}}, - options: [], - report(error) { - reportedError = error; - }, - }; - const handlers = requireExportedTypesRule.create(context); - handlers.Program({type: 'Program'}); // eslint-disable-line new-cap - assert.equal(reportedError.messageId, 'noTypeInfo'); - }); - }); - - describe('underscore prefix handling', () => { - test('skips types starting with underscore', () => { - const errors = []; - const context = { - filename: '/project/source/foo.d.ts', - sourceCode: { - parserServices: { - program: { - getTypeChecker: () => ({getSymbolAtLocation: () => null}), - getSourceFile: () => null, - }, - esTreeNodeToTSNodeMap: new Map(), - }, - getAncestors: () => [], - }, - options: [], - report: error => errors.push(error), - }; - - const handlers = requireExportedTypesRule.create(context); - const typeHandler = handlers['ExportNamedDeclaration > TSTypeAliasDeclaration']; - - typeHandler({id: {name: '_InternalType'}}); - assert.equal(errors.length, 0); - }); - - test('reports types not starting with underscore', () => { - const errors = []; - const context = { - filename: '/project/source/foo.d.ts', - sourceCode: { - parserServices: { - program: { - getTypeChecker: () => ({getSymbolAtLocation: () => null}), - getSourceFile: () => null, - }, - esTreeNodeToTSNodeMap: new Map(), - }, - getAncestors: () => [], - }, - options: [], - report: error => errors.push(error), - }; - - const handlers = requireExportedTypesRule.create(context); - const typeHandler = handlers['ExportNamedDeclaration > TSTypeAliasDeclaration']; - - typeHandler({id: {name: 'PublicType'}}); - assert.equal(errors.length, 1); - assert.equal(errors[0].data.typeName, 'PublicType'); - assert.equal(errors[0].messageId, 'missingExport'); - }); - - test('skips interfaces starting with underscore', () => { - const errors = []; - const context = { - filename: '/project/source/foo.d.ts', - sourceCode: { - parserServices: { - program: { - getTypeChecker: () => ({getSymbolAtLocation: () => null}), - getSourceFile: () => null, - }, - esTreeNodeToTSNodeMap: new Map(), - }, - getAncestors: () => [], - }, - options: [], - report: error => errors.push(error), - }; - - const handlers = requireExportedTypesRule.create(context); - const interfaceHandler = handlers['ExportNamedDeclaration > TSInterfaceDeclaration']; - - interfaceHandler({id: {name: '_InternalInterface'}}); - assert.equal(errors.length, 0); - }); - - test('reports interfaces not starting with underscore', () => { - const errors = []; - const context = { - filename: '/project/source/foo.d.ts', - sourceCode: { - parserServices: { - program: { - getTypeChecker: () => ({getSymbolAtLocation: () => null}), - getSourceFile: () => null, - }, - esTreeNodeToTSNodeMap: new Map(), - }, - getAncestors: () => [], - }, - options: [], - report: error => errors.push(error), - }; - - const handlers = requireExportedTypesRule.create(context); - const interfaceHandler = handlers['ExportNamedDeclaration > TSInterfaceDeclaration']; - - interfaceHandler({id: {name: 'PublicInterface'}}); - assert.equal(errors.length, 1); - assert.equal(errors[0].data.typeName, 'PublicInterface'); - }); - }); - - describe('declare namespace handling', () => { - test('skips types inside declare namespace', () => { - const errors = []; - const context = { - filename: '/project/source/foo.d.ts', - sourceCode: { - parserServices: { - program: { - getTypeChecker: () => ({getSymbolAtLocation: () => null}), - getSourceFile: () => null, - }, - esTreeNodeToTSNodeMap: new Map(), - }, - getAncestors: () => [ - {type: 'TSModuleDeclaration', declare: true}, - ], - }, - options: [], - report: error => errors.push(error), - }; - - const handlers = requireExportedTypesRule.create(context); - const typeHandler = handlers['ExportNamedDeclaration > TSTypeAliasDeclaration']; - - typeHandler({id: {name: 'TypeInNamespace'}}); - assert.equal(errors.length, 0); - }); - - test('reports types outside declare namespace', () => { - const errors = []; - const context = { - filename: '/project/source/foo.d.ts', - sourceCode: { - parserServices: { - program: { - getTypeChecker: () => ({getSymbolAtLocation: () => null}), - getSourceFile: () => null, - }, - esTreeNodeToTSNodeMap: new Map(), - }, - getAncestors: () => [ - {type: 'TSModuleDeclaration', declare: false}, // Not a declare namespace - ], - }, - options: [], - report: error => errors.push(error), - }; - - const handlers = requireExportedTypesRule.create(context); - const typeHandler = handlers['ExportNamedDeclaration > TSTypeAliasDeclaration']; - - typeHandler({id: {name: 'TypeOutsideNamespace'}}); - assert.equal(errors.length, 1); - assert.equal(errors[0].data.typeName, 'TypeOutsideNamespace'); - }); - - test('handles nested declare namespaces', () => { - const errors = []; - const context = { - filename: '/project/source/foo.d.ts', - sourceCode: { - parserServices: { - program: { - getTypeChecker: () => ({getSymbolAtLocation: () => null}), - getSourceFile: () => null, - }, - esTreeNodeToTSNodeMap: new Map(), - }, - getAncestors: () => [ - {type: 'TSModuleDeclaration', declare: false}, - {type: 'TSModuleDeclaration', declare: true}, // At least one is declare - ], - }, - options: [], - report: error => errors.push(error), - }; - - const handlers = requireExportedTypesRule.create(context); - const typeHandler = handlers['ExportNamedDeclaration > TSTypeAliasDeclaration']; - - typeHandler({id: {name: 'NestedType'}}); - assert.equal(errors.length, 0); // Should be skipped - }); - }); - - describe('deduplication', () => { - test('processes each type only once', () => { - const errors = []; - const context = { - filename: '/project/source/foo.d.ts', - sourceCode: { - parserServices: { - program: { - getTypeChecker: () => ({getSymbolAtLocation: () => null}), - getSourceFile: () => null, - }, - esTreeNodeToTSNodeMap: new Map(), - }, - getAncestors: () => [], - }, - options: [], - report: error => errors.push(error), - }; - - const handlers = requireExportedTypesRule.create(context); - const typeHandler = handlers['ExportNamedDeclaration > TSTypeAliasDeclaration']; - - const node = {id: {name: 'DuplicateType'}}; - typeHandler(node); - typeHandler(node); - typeHandler(node); - - assert.equal(errors.length, 1); - }); - - test('processes each interface only once', () => { - const errors = []; - const context = { - filename: '/project/source/foo.d.ts', - sourceCode: { - parserServices: { - program: { - getTypeChecker: () => ({getSymbolAtLocation: () => null}), - getSourceFile: () => null, - }, - esTreeNodeToTSNodeMap: new Map(), - }, - getAncestors: () => [], - }, - options: [], - report: error => errors.push(error), - }; - - const handlers = requireExportedTypesRule.create(context); - const interfaceHandler = handlers['ExportNamedDeclaration > TSInterfaceDeclaration']; - - const node = {id: {name: 'DuplicateInterface'}}; - interfaceHandler(node); - interfaceHandler(node); - - assert.equal(errors.length, 1); - }); - }); - - describe('options', () => { - test('accepts custom index file option', () => { - const context = { - filename: '/project/source/foo.d.ts', - sourceCode: { - parserServices: { - program: { - getTypeChecker: () => ({getSymbolAtLocation: () => null}), - getSourceFile: () => null, - }, - esTreeNodeToTSNodeMap: new Map(), - }, - }, - options: [{indexFile: 'custom-index.d.ts'}], - }; - - const handlers = requireExportedTypesRule.create(context); - assert.ok(handlers['ExportNamedDeclaration > TSTypeAliasDeclaration']); - // The rule should use custom-index.d.ts instead of index.d.ts - }); - - test('uses default index.d.ts when no option provided', () => { - const context = { - filename: '/project/source/foo.d.ts', - sourceCode: { - parserServices: { - program: { - getTypeChecker: () => ({getSymbolAtLocation: () => null}), - getSourceFile: () => null, - }, - esTreeNodeToTSNodeMap: new Map(), - }, - }, - options: [], - }; - - const handlers = requireExportedTypesRule.create(context); - assert.ok(handlers['ExportNamedDeclaration > TSTypeAliasDeclaration']); - }); - }); - - describe('error reporting', () => { - test('includes type name in error data', () => { - const errors = []; - const context = { - filename: '/project/source/foo.d.ts', - sourceCode: { - parserServices: { - program: { - getTypeChecker: () => ({getSymbolAtLocation: () => null}), - getSourceFile: () => null, - }, - esTreeNodeToTSNodeMap: new Map(), - }, - getAncestors: () => [], - }, - options: [], - report: error => errors.push(error), - }; - - const handlers = requireExportedTypesRule.create(context); - const typeHandler = handlers['ExportNamedDeclaration > TSTypeAliasDeclaration']; - - typeHandler({id: {name: 'TestType'}}); - assert.equal(errors[0].messageId, 'missingExport'); - assert.equal(errors[0].data.typeName, 'TestType'); - }); - - test('reports correct node location', () => { - const errors = []; - const testNode = {id: {name: 'LocationTest'}}; - const context = { - filename: '/project/source/foo.d.ts', - sourceCode: { - parserServices: { - program: { - getTypeChecker: () => ({getSymbolAtLocation: () => null}), - getSourceFile: () => null, - }, - esTreeNodeToTSNodeMap: new Map(), - }, - getAncestors: () => [], - }, - options: [], - report: error => errors.push(error), - }; +const packageContent = JSON.stringify({ + name: 'require-exported-types-fixture', +}, null, '\t'); + +const indexContent = 'export {type ExportedType} from \'./source/exported-type\';\n'; +const exportedTypeContent = 'export type ExportedType = {\n\tvalue: string;\n};\n'; +const internalTypeContent = 'export type InternalType = {\n\tvalue: boolean;\n};\n'; +const nestedInternalTypeContent = 'export interface NestedInternalType {\n\tvalue: string;\n}\n'; +const privateTypeContent = 'export type _HiddenType = string;\n'; +const missingTypeContent = 'export type MissingType = {\n\tvalue: number;\n};\n'; +const unexportedMultipleContent = 'export type FirstUnexportedType = string;\nexport interface SecondUnexportedInterface {\n\tvalue: boolean;\n}\n'; +const implementationContent = 'export const value = 1;\n'; +const testDeclarationContent = 'export type Test = number;\n'; +const libDeclarationContent = 'export interface LibTest {\n\tvalue: string;\n}\n'; + +const {ruleTester, fixturePath} = createTypeAwareRuleTester({ + 'package.json': `${packageContent}\n`, + 'index.d.ts': indexContent, + 'source/exported-type.d.ts': undefined, + 'source/internal/internal-type.d.ts': undefined, + 'source/internal/nested/deep.d.ts': undefined, + 'source/private-type.d.ts': undefined, + 'source/missing-type.d.ts': undefined, + 'source/unexported-multiple.d.ts': undefined, + 'source/implementation.ts': undefined, + 'test/test.d.ts': undefined, + 'lib/test.d.ts': undefined, +}); - const handlers = requireExportedTypesRule.create(context); - const typeHandler = handlers['ExportNamedDeclaration > TSTypeAliasDeclaration']; +// Type-aware rule tests rely on the generated fixture project above. +const missingExportError = typeName => ({ + messageId: 'missingExport', + data: {typeName}, +}); - typeHandler(testNode); - assert.equal(errors[0].node, testNode); - }); - }); +ruleTester.run('require-exported-types', requireExportedTypesRule, { + valid: [ + { + code: exportedTypeContent, + filename: fixturePath('source/exported-type.d.ts'), + }, + { + code: privateTypeContent, + filename: fixturePath('source/private-type.d.ts'), + }, + { + code: internalTypeContent, + filename: fixturePath('source/internal/internal-type.d.ts'), + }, + { + code: nestedInternalTypeContent, + filename: fixturePath('source/internal/nested/deep.d.ts'), + }, + { + code: implementationContent, + filename: fixturePath('source/implementation.ts'), + }, + { + code: testDeclarationContent, + filename: fixturePath('test/test.d.ts'), + }, + { + code: libDeclarationContent, + filename: fixturePath('lib/test.d.ts'), + }, + ], + invalid: [ + { + code: missingTypeContent, + filename: fixturePath('source/missing-type.d.ts'), + errors: [missingExportError('MissingType')], + }, + { + code: unexportedMultipleContent, + filename: fixturePath('source/unexported-multiple.d.ts'), + errors: [ + missingExportError('FirstUnexportedType'), + missingExportError('SecondUnexportedInterface'), + ], + }, + ], }); diff --git a/lint-rules/source-files-extension.test.js b/lint-rules/source-files-extension.test.js new file mode 100644 index 000000000..65d9e906c --- /dev/null +++ b/lint-rules/source-files-extension.test.js @@ -0,0 +1,40 @@ +import {createRuleTester} from './test-utils.js'; +import {sourceFilesExtensionRule} from './source-files-extension.js'; + +const ruleTester = createRuleTester(); + +const invalidSourceFile = (filename, code = '') => ({ + code, + filename, + errors: [{messageId: 'incorrectFilename'}], +}); + +ruleTester.run('source-files-extension', sourceFilesExtensionRule, { + valid: [ + // Correct .d.ts extension + { + code: '', + filename: '/source/foo.d.ts', + }, + { + code: '', + filename: '/source/types/bar.d.ts', + }, + { + code: '', + filename: '/source/deeply/nested/file.d.ts', + }, + ], + invalid: [ + // Wrong .ts extension + invalidSourceFile('/source/foo.ts'), + // Wrong .js extension + invalidSourceFile('/source/foo.js'), + // No extension + invalidSourceFile('/source/bar'), + // Wrong .tsx extension + invalidSourceFile('/source/component.tsx'), + // Wrong .mjs extension + invalidSourceFile('/source/module.mjs'), + ], +}); diff --git a/lint-rules/test-utils.js b/lint-rules/test-utils.js new file mode 100644 index 000000000..189a0a866 --- /dev/null +++ b/lint-rules/test-utils.js @@ -0,0 +1,313 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {RuleTester} from 'eslint'; +import tsParser from '@typescript-eslint/parser'; +import dedent from 'dedent'; + +export const createRuleTester = (overrides = {}) => { + const { + languageOptions: overrideLanguageOptions = {}, + ...restOverrides + } = overrides; + + const { + parserOptions: overrideParserOptions = {}, + ...otherLanguageOptions + } = overrideLanguageOptions; + + return new RuleTester({ + ...restOverrides, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + parser: tsParser, + ...otherLanguageOptions, + parserOptions: { + ...overrideParserOptions, + }, + }, + }); +}; + +const defaultTypeAwareTsconfig = { + compilerOptions: { + declaration: true, + emitDeclarationOnly: true, + module: 'ESNext', + moduleResolution: 'Bundler', + skipLibCheck: true, + strict: true, + target: 'ES2022', + }, + include: [ + '*.d.ts', + 'source/**/*.*', + 'lib/**/*.d.ts', + 'test/**/*.d.ts', + ], +}; + +export const createTypeAwareRuleTester = (fixtureFiles, options = {}) => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'type-fest-type-aware-')); + + const writeFixture = (relativePath, content) => { + const absolutePath = path.join(fixtureRoot, relativePath); + fs.mkdirSync(path.dirname(absolutePath), {recursive: true}); + fs.writeFileSync(absolutePath, content ?? ''); + }; + + for (const [relativePath, content] of Object.entries(fixtureFiles)) { + writeFixture(relativePath, content); + } + + const hasRuleTesterOption = Object.hasOwn(options, 'ruleTester'); + const hasTsconfigOption = Object.hasOwn(options, 'tsconfig'); + const ruleTesterOverrides = hasRuleTesterOption || hasTsconfigOption ? options.ruleTester ?? {} : options; + const tsconfig = hasRuleTesterOption || hasTsconfigOption + ? options.tsconfig ?? defaultTypeAwareTsconfig + : defaultTypeAwareTsconfig; + + if (!('tsconfig.json' in fixtureFiles)) { + writeFixture('tsconfig.json', `${JSON.stringify(tsconfig, null, '\t')}\n`); + } + + const overrideLanguageOptions = ruleTesterOverrides.languageOptions ?? {}; + const overrideParserOptions = overrideLanguageOptions.parserOptions ?? {}; + const overrideProjectService = overrideParserOptions.projectService ?? {}; + const ruleTester = createRuleTester({ + ...ruleTesterOverrides, + languageOptions: { + ...overrideLanguageOptions, + parserOptions: { + ...overrideParserOptions, + projectService: { + allowDefaultProject: ['*.ts*'], + ...overrideProjectService, + }, + tsconfigRootDir: fixtureRoot, + }, + }, + }); + + const fixturePath = relativePath => path.join(fixtureRoot, relativePath); + + return { + ruleTester, + fixtureRoot, + fixturePath, + writeFixture, + }; +}; + +export const dedenter = dedent.withOptions({alignValues: true}); + +/** +Returns the specified code in a fenced code block, with an optional language tag. + +@example +``` +fence('type A = string;'); +// Returns: +// ``` +// type A = string; +// ``` + +fence(`import {RemovePrefix} from 'type-fest'; + +type A = RemovePrefix<'onChange', 'on'>; +//=> 'Change'`, 'ts'); +// Returns: +// ```ts +// import {RemovePrefix} from 'type-fest'; + +// type A = RemovePrefix<'onChange', 'on'>; +// //=> 'Change' +// ``` +``` +*/ +export const fence = (code, lang = '') => + dedenter` + \`\`\`${lang} + ${code} + \`\`\` + `; + +/** +Returns the specified lines as a JSDoc comment, placing each specified line on a new line. + +@example +``` +jsdoc('Some description.', 'Note: Some note.'); +// Returns: +// /** +// Some description. +// Note: Some note. +// *​/ + +jsdoc('@example', '```\ntype A = string;\n```', '@category Test'); +// Returns; +// /** +// @example +// ``` +// type A = string; +// ``` +// @category Test +// *​/ +``` +*/ +export const jsdoc = (...lines) => + dedenter` + /** + ${lines.join('\n')} + */ + `; + +/** +Returns an exported type for each provided prefix, with each prefix placed directly above its corresponding type declaration. + +@example +``` +exportType( + '// Some comment', + 'type Test = string;', + '/**\nSome description.\nNote: Some note.\n*​/' +); +// Returns: +// // Some comment +// export type T0 = string; +// +// type Test = string; +// export type T1 = string; +// +// /** +// Some description. +// Note: Some note. +// *​/ +// export type T2 = string; +*/ +export const exportType = (...prefixes) => + prefixes + .map((doc, i) => dedenter` + ${doc} + export type T${i} = string; + `) + .join('\n\n'); + +/** +Returns an exported "Options" object type containing a property for each specified prefix, with each prefix placed directly above its corresponding property declaration. + +@example +``` +exportOption( + '// Some comment', + 'type Test = string;', + '/**\nSome description.\nNote: Some note.\n*​/' +); +// Returns: +// export type TOptions = { +// // Some comment +// p0: string; + +// test: string; +// p1: string; + +// /** +// Some description. +// Note: Some note. +// *​/ +// p2: string; +// }; +``` +*/ +export const exportOption = (...prefixes) => + dedenter` + export type TOptions = { + ${prefixes + .map((doc, i) => dedenter` + ${doc} + p${i}: string; + `) + .join('\n\n')} + }; + `; + +/** +Returns an exported type for each provided prefix, and an exported "Options" object type containing a property for each specified prefix, with each prefix placed directly above its corresponding declaration. + +@example +``` +exportTypeAndOption('// Some comment', '/**\nSome JSDoc\n*​/'); +// Returns: +// // Some comment +// type T0 = string; + +// /** +// Some JSDoc +// *​/ +// type T1 = string; + +// type TOptions = { +// // Some comment +// p0: string; + +// /** +// Some JSDoc +// *​/ +// p1: string; +// }; +``` +*/ +export const exportTypeAndOption = (...prefixes) => + dedenter` + ${exportType(...prefixes)} + + ${exportOption(...prefixes)} + `; + +/** +@typedef {{ + line: number; + textBeforeStart: string; + ruleId?: string; + messageId?: string; +} & ({ target: string } | { endLine: number; textBeforeEnd: string })} ErrorAtProps + +@param {ErrorAtProps} props +@returns {{line: number, column: number, endLine: number, endColumn: number, ruleId?: string, messageId?: string}} +*/ +export const errorAt = props => { + const {line, textBeforeStart, ruleId, messageId} = props; + + const column = textBeforeStart.length + 1; + const endColumn = 'textBeforeEnd' in props ? props.textBeforeEnd.length + 1 : column + props.target.length; + + const endLine = 'endLine' in props ? props.endLine : line; + + return { + ...(ruleId && {ruleId}), + ...(messageId && {messageId}), + line, // 1-based, inclusive + column, // 1-based, inclusive + endLine, // 1-based, inclusive + endColumn, // 1-based, exclusive + }; +}; + +/// Code samples +export const code1 = dedenter` +import type {Sum} from 'type-fest'; + +type A = Sum<1, 2>; +//=> 3 +`; + +export const code2 = dedenter` +import type {LiteralToPrimitiveDeep} from 'type-fest'; + +const config = {appName: 'MyApp', version: '1.0.0'} as const; + +declare function updateConfig(newConfig: LiteralToPrimitiveDeep): void; + +updateConfig({appName: 'MyUpdatedApp', version: '2.0.0'}); +`; diff --git a/lint-rules/validate-jsdoc-codeblocks.js b/lint-rules/validate-jsdoc-codeblocks.js new file mode 100644 index 000000000..46871e67f --- /dev/null +++ b/lint-rules/validate-jsdoc-codeblocks.js @@ -0,0 +1,360 @@ +import path from 'node:path'; +import ts from 'typescript'; +import {createFSBackedSystem, createVirtualTypeScriptEnvironment} from '@typescript/vfs'; + +const CODEBLOCK_REGEX = /(?```(?:ts|typescript)?\n)(?[\s\S]*?)```/g; +const FILENAME = 'example-codeblock.ts'; +const TWOSLASH_COMMENT = '//=>'; + +const compilerOptions = { + lib: ['lib.es2023.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'], + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.Node20, + moduleResolution: ts.ModuleResolutionKind.Node16, + strict: true, + noImplicitReturns: true, + noImplicitOverride: true, + noUnusedLocals: false, // This is intentionally disabled + noUnusedParameters: true, + noFallthroughCasesInSwitch: true, + noUncheckedIndexedAccess: true, + noPropertyAccessFromIndexSignature: true, + noUncheckedSideEffectImports: true, + useDefineForClassFields: true, + exactOptionalPropertyTypes: true, +}; + +const virtualFsMap = new Map(); +virtualFsMap.set(FILENAME, '// Can\'t be empty'); + +const rootDir = path.join(import.meta.dirname, '..'); +const system = createFSBackedSystem(virtualFsMap, rootDir, ts); +const defaultEnv = createVirtualTypeScriptEnvironment(system, [FILENAME], ts, compilerOptions); + +function parseCompilerOptions(code) { + const options = {}; + const lines = code.split('\n'); + + for (const line of lines) { + if (!line.trim()) { + // Skip empty lines + continue; + } + + const match = line.match(/^\s*\/\/ @(\w+): (.*)$/); + if (!match) { + // Stop parsing at the first non-matching line + return options; + } + + const [, key, value] = match; + const trimmedValue = value.trim(); + + try { + options[key] = JSON.parse(trimmedValue); + } catch { + options[key] = trimmedValue; + } + } + + return options; +} + +function getJSDocNode(sourceCode, node) { + let previousToken = sourceCode.getTokenBefore(node, {includeComments: true}); + + // Skip over any line comments immediately before the node + while (previousToken && previousToken.type === 'Line') { + previousToken = sourceCode.getTokenBefore(previousToken, {includeComments: true}); + } + + if (previousToken && previousToken.type === 'Block' && previousToken.value.startsWith('*')) { + return previousToken; + } + + return undefined; +} + +export const validateJSDocCodeblocksRule = /** @type {const} */ ({ + meta: { + type: 'suggestion', + docs: { + description: 'Ensures JSDoc example codeblocks don\'t have errors', + }, + fixable: 'code', + messages: { + invalidCodeblock: '{{errorMessage}}', + typeMismatch: 'Expected twoslash comment to be: {{expectedComment}}, but found: {{actualComment}}', + }, + schema: [], + }, + defaultOptions: [], + create(context) { + const filename = context.filename.replaceAll('\\', '/'); + + // Skip internal files + if (filename.includes('/internal/')) { + return {}; + } + + try { + defaultEnv.updateFile(context.filename, context.sourceCode.getText()); + } catch { + // Ignore + } + + return { + TSTypeAliasDeclaration(node) { + const {parent} = node; + + // Skip if type is not exported or starts with an underscore (private/internal) + if (parent.type !== 'ExportNamedDeclaration' || node.id.name.startsWith('_')) { + return; + } + + const previousNodes = []; + const jsdocForExport = getJSDocNode(context.sourceCode, parent); + if (jsdocForExport) { + previousNodes.push(jsdocForExport); + } + + // Handle JSDoc blocks for options + if (node.id.name.endsWith('Options') && node.typeAnnotation.type === 'TSTypeLiteral') { + for (const member of node.typeAnnotation.members) { + const jsdocForMember = getJSDocNode(context.sourceCode, member); + if (jsdocForMember) { + previousNodes.push(jsdocForMember); + } + } + } + + for (const previousNode of previousNodes) { + const comment = previousNode.value; + + for (const match of comment.matchAll(CODEBLOCK_REGEX)) { + const {code, openingFence} = match.groups ?? {}; + + // Skip empty code blocks + if (!code || !openingFence) { + continue; + } + + const matchOffset = match.index + openingFence.length + 2; // Add `2` because `comment` doesn't include the starting `/*` + const codeStartIndex = previousNode.range[0] + matchOffset; + + const overrides = parseCompilerOptions(code); + let env = defaultEnv; + + if (Object.keys(overrides).length > 0) { + const {options, errors} = ts.convertCompilerOptionsFromJson(overrides, rootDir); + + if (errors.length === 0) { + // Create a new environment with overridden options + env = createVirtualTypeScriptEnvironment(system, [FILENAME], ts, {...compilerOptions, ...options}); + } + } + + env.updateFile(FILENAME, code); + const syntacticDiagnostics = env.languageService.getSyntacticDiagnostics(FILENAME); + const semanticDiagnostics = env.languageService.getSemanticDiagnostics(FILENAME); + const diagnostics = syntacticDiagnostics.length > 0 ? syntacticDiagnostics : semanticDiagnostics; // Show semantic errors only if there are no syntactic errors + + for (const diagnostic of diagnostics) { + // If diagnostic location is not available, report on the entire code block + const diagnosticStart = codeStartIndex + (diagnostic.start ?? 0); + const diagnosticEnd = diagnosticStart + (diagnostic.length ?? code.length); + + context.report({ + loc: { + start: context.sourceCode.getLocFromIndex(diagnosticStart), + end: context.sourceCode.getLocFromIndex(diagnosticEnd), + }, + messageId: 'invalidCodeblock', + data: { + errorMessage: ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'), + }, + }); + } + + if (diagnostics.length === 0) { + validateTwoslashTypes(context, env, code, codeStartIndex); + } + } + } + }, + }; + }, +}); + +function extractTypeFromQuickInfo(quickInfo) { + const {displayParts} = quickInfo; + + // For interfaces and enums, return everything after the keyword + const keywordIndex = displayParts.findIndex( + part => part.kind === 'keyword' && ['interface', 'enum'].includes(part.text), + ); + + if (keywordIndex !== -1) { + return displayParts.slice(keywordIndex + 1).map(part => part.text).join('').trim(); + } + + let depth = 0; + const separatorIndex = displayParts.findIndex(part => { + if (part.kind === 'punctuation') { + if (['(', '{', '<'].includes(part.text)) { + depth++; + } else if ([')', '}', '>'].includes(part.text)) { + depth--; + } else if (part.text === ':' && depth === 0) { + return true; + } + } else if (part.kind === 'operator' && part.text === '=' && depth === 0) { + return true; + } + + return false; + }); + + // If `separatorIndex` is `-1` (not found), return the entire thing + return displayParts.slice(separatorIndex + 1).map(part => part.text).join('').trim(); +} + +function normalizeUnions(type) { + const sourceFile = ts.createSourceFile( + 'twoslash-type.ts', + `declare const test: ${type};`, + ts.ScriptTarget.Latest, + ); + + const typeNode = sourceFile.statements[0].declarationList.declarations[0].type; + + const print = node => ts.createPrinter().printNode(ts.EmitHint.Unspecified, node, sourceFile); + const isNumeric = v => v.trim() !== '' && Number.isFinite(Number(v)); + + const visit = node => { + node = ts.visitEachChild(node, visit, undefined); + + if (ts.isUnionTypeNode(node)) { + const types = node.types + .map(t => [print(t), t]) + .sort(([a], [b]) => + // Numbers are sorted only wrt other numbers + isNumeric(a) && isNumeric(b) ? Number(a) - Number(b) : 0, + ) + .map(t => t[1]); + + return ts.factory.updateUnionTypeNode( + node, + ts.factory.createNodeArray(types), + ); + } + + // Prefer single-line formatting for tuple types + if (ts.isTupleTypeNode(node)) { + const updated = ts.factory.createTupleTypeNode(node.elements); + ts.setEmitFlags(updated, ts.EmitFlags.SingleLine); + return updated; + } + + // Replace double-quoted string literals with single-quoted ones + if (ts.isStringLiteral(node)) { + const updated = ts.factory.createStringLiteral(node.text, true); + // Preserve non-ASCII characters like emojis. + ts.setEmitFlags(updated, ts.EmitFlags.NoAsciiEscaping); + return updated; + } + + return node; + }; + + return print(visit(typeNode)).replaceAll(/^( +)/gm, indentation => { + // Replace spaces used for indentation with tabs + const spacesPerTab = 4; + const tabCount = Math.floor(indentation.length / spacesPerTab); + const remainingSpaces = indentation.length % spacesPerTab; + return '\t'.repeat(tabCount) + ' '.repeat(remainingSpaces); + }); +} + +function validateTwoslashTypes(context, env, code, codeStartIndex) { + const sourceFile = env.languageService.getProgram().getSourceFile(FILENAME); + const lines = code.split('\n'); + + for (const [index, line] of lines.entries()) { + const dedentedLine = line.trimStart(); + if (!dedentedLine.startsWith(TWOSLASH_COMMENT)) { + continue; + } + + const previousLineIndex = index - 1; + if (previousLineIndex < 0) { + continue; + } + + let actualComment = dedentedLine; + let actualCommentEndLine = index; + + for (let i = index + 1; i < lines.length; i++) { + const dedentedNextLine = lines[i].trimStart(); + if (!dedentedNextLine.startsWith('//') || dedentedNextLine.startsWith(TWOSLASH_COMMENT)) { + break; + } + + actualComment += '\n' + dedentedNextLine; + actualCommentEndLine = i; + } + + const previousLine = lines[previousLineIndex]; + const previousLineOffset = sourceFile.getPositionOfLineAndCharacter(previousLineIndex, 0); + + for (let i = 0; i < previousLine.length; i++) { + const quickInfo = env.languageService.getQuickInfoAtPosition(FILENAME, previousLineOffset + i); + + if (quickInfo?.displayParts) { + let expectedType = normalizeUnions(extractTypeFromQuickInfo(quickInfo)); + + if (expectedType.length < 80) { + expectedType = expectedType + .replaceAll(/\r?\n\s*/g, ' ') // Collapse into single line + .replaceAll(/{\s+/g, '{') // Remove spaces after `{` + .replaceAll(/\s+}/g, '}') // Remove spaces before `}` + .replaceAll(/;(?=})/g, ''); // Remove semicolons before `}` + } + + const expectedComment = TWOSLASH_COMMENT + ' ' + expectedType.replaceAll('\n', '\n// '); + + if (actualComment !== expectedComment) { + const actualCommentIndex = line.indexOf(TWOSLASH_COMMENT); + + const actualCommentStartOffset = sourceFile.getPositionOfLineAndCharacter(index, actualCommentIndex); + const actualCommentEndOffset = sourceFile.getPositionOfLineAndCharacter(actualCommentEndLine, lines[actualCommentEndLine].length); + + const start = codeStartIndex + actualCommentStartOffset; + const end = codeStartIndex + actualCommentEndOffset; + + context.report({ + loc: { + start: context.sourceCode.getLocFromIndex(start), + end: context.sourceCode.getLocFromIndex(end), + }, + messageId: 'typeMismatch', + data: { + expectedComment, + actualComment, + }, + fix(fixer) { + const indent = line.slice(0, actualCommentIndex); + + return fixer.replaceTextRange( + [start, end], + expectedComment.replaceAll('\n', `\n${indent}`), + ); + }, + }); + } + + break; + } + } + } +} diff --git a/lint-rules/validate-jsdoc-codeblocks.test.js b/lint-rules/validate-jsdoc-codeblocks.test.js new file mode 100644 index 000000000..6058040de --- /dev/null +++ b/lint-rules/validate-jsdoc-codeblocks.test.js @@ -0,0 +1,1313 @@ +import {code1, code2, createRuleTester, dedenter, errorAt as errorAt_, exportType, exportTypeAndOption, fence, jsdoc} from './test-utils.js'; +import {validateJSDocCodeblocksRule} from './validate-jsdoc-codeblocks.js'; + +const ruleTester = createRuleTester(); + +const codeWithErrors = dedenter` +import type {RemovePrefix} from 'type-fest'; + +type A = RemovePrefix<'on-change', string, {strict: "yes"}>; +`; + +const invalidCodeblockErrorAt = props => errorAt_({...props, messageId: 'invalidCodeblock'}); +const typeMismatchErrorAt = props => errorAt_({...props, messageId: 'typeMismatch'}); + +ruleTester.run('validate-jsdoc-codeblocks', validateJSDocCodeblocksRule, { + valid: [ + // Not exported + dedenter` + ${jsdoc(fence(codeWithErrors))} + type NotExported = string; + `, + dedenter` + type NotExportedOptions = { + ${jsdoc(fence(codeWithErrors))} + p1: string; + } + `, + + // Internal (leading underscore) + dedenter` + ${jsdoc(fence(codeWithErrors))} + export type _Internal = string; + `, + dedenter` + export type _InternalOptions = { + ${jsdoc(fence(codeWithErrors))} + p1: string; + } + `, + + // Without `Options` suffix + dedenter` + export type NoSuffix = { + ${jsdoc(fence(codeWithErrors))} + p1: string; + } + `, + + // No JSDoc + exportTypeAndOption(''), + exportType('type Some = number;'), + exportTypeAndOption('// Not block comment'), + exportTypeAndOption('/* Block comment, but not JSDoc */'), + + // No codeblock in JSDoc + exportType(jsdoc('No codeblock here')), + + // With text before and after + exportTypeAndOption(jsdoc('Some description.', fence(code1), '@category Test')), + + // With line breaks before and after + exportTypeAndOption( + jsdoc('Some description.\n', 'Note: Some note.\n', fence(code1, 'ts'), '\n@category Test'), + ), + + // With `@example` tag + exportTypeAndOption(jsdoc('@example', fence(code1))), + + // With language specifiers + exportTypeAndOption(jsdoc(fence(code1, 'ts'))), + exportTypeAndOption(jsdoc(fence(code1, 'typescript'))), + + // Multiple code blocks + exportTypeAndOption( + jsdoc('@example', fence(code1, 'ts'), '\nSome text in between.\n', '@example', fence(code2)), + ), + + // Multiple exports and multiple properties + exportTypeAndOption(jsdoc(fence(code1)), jsdoc(fence(code2))), + + // With @ts-expect-error + exportTypeAndOption(jsdoc(fence(dedenter` + import type {ExtractStrict} from 'type-fest'; + + // @ts-expect-error + type A = ExtractStrict<'foo' | 'bar', 'baz'>; + `))), + + // Indented code blocks + exportTypeAndOption(jsdoc( + 'Note:', + dedenter` + 1. First point + \`\`\`ts + import type {Subtract} from 'type-fest'; + type A = Subtract<1, 2>; + \`\`\` + 2. Second point + \`\`\`ts + import type {Sum} from 'type-fest'; + type A = Sum<1, 2>; + \`\`\` + `, + )), + + // Compiler options overrides + exportTypeAndOption(jsdoc(fence(dedenter` + // @exactOptionalPropertyTypes: false + const foo: {a?: number} = {a: undefined}; + `))), + + // Incorrect compiler options are ignored + exportTypeAndOption(jsdoc(fence(dedenter` + // @noUnusedLocals: 'invalid-value' + const foo = {a: 1}; + `))), + + // Line comment between JSDoc and type/option + exportTypeAndOption(dedenter` + ${jsdoc(fence(code1))} + // Some line comment between JSDoc and export + `), + ], + invalid: [ + // With text before and after + { + code: dedenter` + /** + Some description. + \`\`\`ts + type A = Subtract<1, 2>; + \`\`\` + @category Test + */ + export type T0 = string; + + export type TOptions = { + /** + Some description. + \`\`\`ts + type A = Subtract<1, 2>; + \`\`\` + @category Test + */ + p0: string; + }; + `, + errors: [ + invalidCodeblockErrorAt({line: 4, textBeforeStart: 'type A = ', target: 'Subtract'}), + invalidCodeblockErrorAt({line: 14, textBeforeStart: '\ttype A = ', target: 'Subtract'}), + ], + }, + + // With line breaks before and after + { + code: dedenter` + /** + Some description. + + Note: Some note. + + \`\`\`ts + type A = Subtract<1, 2>; + \`\`\` + + @category Test + */ + export type T0 = string; + + export type TOptions = { + /** + Some description. + + Note: Some note. + + \`\`\`ts + type A = Subtract<1, 2>; + \`\`\` + + @category Test + */ + p0: string; + }; + `, + errors: [ + invalidCodeblockErrorAt({line: 7, textBeforeStart: 'type A = ', target: 'Subtract'}), + invalidCodeblockErrorAt({line: 21, textBeforeStart: '\ttype A = ', target: 'Subtract'}), + ], + }, + + // With `@example` tag + { + code: dedenter` + /** + @example + \`\`\`ts + type A = Subtract<1, 2>; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + @example + \`\`\`ts + type A = Subtract<1, 2>; + \`\`\` + */ + p0: string; + }; + `, + errors: [ + invalidCodeblockErrorAt({line: 4, textBeforeStart: 'type A = ', target: 'Subtract'}), + invalidCodeblockErrorAt({line: 13, textBeforeStart: '\ttype A = ', target: 'Subtract'}), + ], + }, + + // With language specifiers + { + code: dedenter` + /** + \`\`\`ts + type A = Subtract<1, 2>; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + \`\`\`ts + type A = Subtract<1, 2>; + \`\`\` + */ + p0: string; + }; + `, + errors: [ + invalidCodeblockErrorAt({line: 3, textBeforeStart: 'type A = ', target: 'Subtract'}), + invalidCodeblockErrorAt({line: 11, textBeforeStart: '\ttype A = ', target: 'Subtract'}), + ], + }, + { + code: dedenter` + /** + \`\`\`typescript + type A = Subtract<1, 2>; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + \`\`\`typescript + type A = Subtract<1, 2>; + \`\`\` + */ + p0: string; + }; + `, + errors: [ + invalidCodeblockErrorAt({line: 3, textBeforeStart: 'type A = ', target: 'Subtract'}), + invalidCodeblockErrorAt({line: 11, textBeforeStart: '\ttype A = ', target: 'Subtract'}), + ], + }, + + // Multiple code blocks + { + code: dedenter` + /** + @example + \`\`\`ts + type A = Subtract<1, 2>; + \`\`\` + + Some text in between. + + @example + \`\`\`ts + import type {ExcludeStrict} from 'type-fest'; + + type A = ExcludeStrict; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + @example + \`\`\`ts + type A = Subtract<1, 2>; + \`\`\` + + Some text in between. + + @example + \`\`\`ts + import type {ExcludeStrict} from 'type-fest'; + + type A = ExcludeStrict; + \`\`\` + */ + p0: string; + }; + `, + errors: [ + invalidCodeblockErrorAt({line: 4, textBeforeStart: 'type A = ', target: 'Subtract'}), + invalidCodeblockErrorAt({line: 13, textBeforeStart: 'type A = ExcludeStrict; + \`\`\` + */ + export type T0 = string; + + /** + \`\`\`ts + import type {ExcludeStrict} from 'type-fest'; + + type A = ExcludeStrict; + \`\`\` + */ + export type T1 = string; + + export type T0Options = { + /** + \`\`\`ts + type A = Subtract<1, 2>; + \`\`\` + */ + p0: string; + + /** + \`\`\`ts + import type {ExcludeStrict} from 'type-fest'; + + type A = ExcludeStrict; + \`\`\` + */ + p1: string; + }; + + export type T1Options = { + /** + \`\`\`ts + import type {Sum} from 'type-fest'; + + Sum<1, 2>; //=> 3 + \`\`\` + */ + p0: string; + }; + `, + errors: [ + invalidCodeblockErrorAt({line: 3, textBeforeStart: 'type A = ', target: 'Subtract'}), + invalidCodeblockErrorAt({line: 12, textBeforeStart: 'type A = ExcludeStrict; + \`\`\` + 2. Second point + \`\`\`ts + type A = Sum<1, 2>; + \`\`\` + */ + export type T0 = string; + + export type TOptions = { + /** + Note: + 1. First point + \`\`\`ts + type A = Subtract<1, 2>; + \`\`\` + 2. Second point + \`\`\`ts + type A = Sum<1, 2>; + \`\`\` + */ + p0: string; + }; + `, + errors: [ + invalidCodeblockErrorAt({line: 5, textBeforeStart: '\ttype A = ', target: 'Subtract'}), + invalidCodeblockErrorAt({line: 9, textBeforeStart: '\ttype A = ', target: 'Sum'}), + invalidCodeblockErrorAt({line: 19, textBeforeStart: '\t\ttype A = ', target: 'Subtract'}), + invalidCodeblockErrorAt({line: 23, textBeforeStart: '\t\ttype A = ', target: 'Sum'}), + ], + }, + + // Missing import + { + code: dedenter` + /** + Description + \`\`\` + type A = Sum<1, 2>; + //=> 3 + + type B = Sum<-1, 2>; + //=> 1 + \`\`\` + */ + export type Sum = string; + `, + errors: [ + invalidCodeblockErrorAt({line: 4, textBeforeStart: 'type A = ', target: 'Sum'}), + invalidCodeblockErrorAt({line: 7, textBeforeStart: 'type B = ', target: 'Sum'}), + ], + }, + + // Floating examples + { + code: dedenter` + /** + \`\`\`ts + import type {IsUppercase} from 'type-fest'; + + IsUppercase<'ABC'>; + //=> true + + IsUppercase<'Abc'>; + //=> false + \`\`\` + @category Utilities + */ + export type IsUppercase = boolean; + `, + errors: [ + invalidCodeblockErrorAt({line: 5, textBeforeStart: '', target: 'IsUppercase'}), + invalidCodeblockErrorAt({line: 8, textBeforeStart: '', target: 'IsUppercase'}), + ], + }, + + // Hypthetical references + { + code: dedenter` + /** + Some description + Some note + \`\`\` + import type {Except} from 'type-fest'; + + type PostPayload = Except; + \`\`\` + */ + export type Except = string; + `, + errors: [ + invalidCodeblockErrorAt({line: 7, textBeforeStart: 'type PostPayload = Except<', target: 'UserData'}), + ], + }, + + // Duplicate identifiers + { + code: dedenter` + export type IsTupleOptions = { + /** + @example + \`\`\` + import type {IsTuple} from 'type-fest'; + + type Example = IsTuple<[number, ...number[]], {fixedLengthOnly: true}>; + //=> false + + type Example = IsTuple<[number, ...number[]], {fixedLengthOnly: false}>; + //=> true + \`\`\` + @default true + */ + fixedLengthOnly: boolean; + }; + `, + errors: [ + invalidCodeblockErrorAt({line: 7, textBeforeStart: '\ttype ', target: 'Example'}), + invalidCodeblockErrorAt({line: 10, textBeforeStart: '\ttype ', target: 'Example'}), + ], + }, + + // Multi line error + { + code: dedenter` + /** + @example + \`\`\` + declare function updateConfig(newConfig: {name?: string; version?: number}): void; + + updateConfig({ + name: undefined, + version: undefined, + }); + \`\`\` + @category Utilities + */ + export type MultiLine = string; + `, + errors: [ + invalidCodeblockErrorAt({line: 6, textBeforeStart: 'updateConfig(', endLine: 9, textBeforeEnd: '}'}), + ], + }, + + // Precise one character error + { + code: dedenter` + /** + \`\`\` + import type {ExcludeStrict} from 'type-fest'; + + type A = ExcludeStrict<'a' | 'b', 'A'>; + \`\`\` + */ + export type ExcludeStrict = string; + `, + errors: [ + invalidCodeblockErrorAt({ + line: 5, + textBeforeStart: 'type A = ExcludeStrict<\'a\' | \'b\', ', + target: '\'A\'', + }), + ], + }, + + // `exactOptionalPropertyTypes` is enabled + { + code: dedenter` + /** + \`\`\` + const test: {foo?: string} = {foo: undefined}; + \`\`\` + */ + export type Test = string; + `, + errors: [ + invalidCodeblockErrorAt({line: 3, textBeforeStart: 'const ', target: 'test'}), + ], + }, + + // Overlapping errors + { + code: dedenter` + /** + \`\`\`typescript + import type {ExcludeStrict, Sum} from 'type-fest'; + + type A = Sum<1, '2'>; + \`\`\` + */ + export type Test = string; + `, + errors: [ + invalidCodeblockErrorAt({line: 5, textBeforeStart: 'type A = ', target: 'Sum<1, \'2\'>'}), + invalidCodeblockErrorAt({line: 5, textBeforeStart: 'type A = Sum<1, ', target: '\'2\''}), + ], + }, + + // Compiler options overrides + { + code: dedenter` + /** + \`\`\`ts + // @noUnusedLocals: true + const foo = {a: 1}; + \`\`\` + */ + export type T0 = string; + `, + errors: [ + invalidCodeblockErrorAt({line: 4, textBeforeStart: 'const ', target: 'foo'}), + ], + }, + ], +}); + +// Type mismatch tests +ruleTester.run('validate-jsdoc-codeblocks', validateJSDocCodeblocksRule, { + valid: [ + exportTypeAndOption(jsdoc(fence(dedenter` + type Foo = string; + //=> string + `))), + + // No twoslash comment at all + exportTypeAndOption(jsdoc(fence(dedenter` + const foo = 'bar'; + `))), + + // Twoslash comment at very first line + exportTypeAndOption(jsdoc(fence(dedenter` + //=> 'bar' + const foo = 'bar'; + `))), + + // Object type collapsed into single line + exportTypeAndOption(jsdoc(fence(dedenter` + const foo = {a: 1, b: {c: 'c'}}; + //=> {a: number; b: {c: string}} + `))), + + // Multiline type + exportTypeAndOption(jsdoc(fence(dedenter` + import type {Simplify} from 'type-fest'; + + type Foo = {readonly a: number; readonly b?: number}; + type Bar = {c?: string; d: {readonly e: boolean}; e: string}; + type Baz = Simplify; + //=> { + // readonly a: number; + // readonly b?: number; + // c?: string; + // d: { + // readonly e: boolean; + // }; + // e: string; + // } + `))), + + // Quick info at 0th index + exportTypeAndOption(jsdoc(fence(dedenter` + let foo = 1; + foo++; + //=> number + `))), + + // Quick info at some middle index + exportTypeAndOption(jsdoc(fence(dedenter` + const foo = 1 as string | number; + //=> string | number + `))), + + // Quick info at last index + exportTypeAndOption(jsdoc(fence(dedenter` + const foo = {n: 1} + const bar = foo + .n + //=> number + `))), + + // Double-quotes properly replaced + exportTypeAndOption(jsdoc(fence(dedenter` + import type {Simplify} from 'type-fest'; + + type Foo = {a: 'abc'; b: 123; c: 'def'}; + type Bar = {x: {y: 'y'; z: 'z'}}; + type Baz = Simplify; + //=> {a: 'abc'; b: 123; c: 'def'; x: {y: 'y'; z: 'z'}} + `))), + exportTypeAndOption(jsdoc(fence(dedenter` + type Foo = 'a"b"c'; + //=> 'a"b"c' + + type Bar = "d'e'f"; + //=> 'd\'e\'f' + `))), + + // Space indentation properly replaced + exportTypeAndOption(jsdoc(fence(dedenter` + import type {Simplify} from 'type-fest'; + + type Foo = { + a: { + ab: boolean; + ac: { + acd: string | number; + }; + }; + e: [ + { + fgh: false; + ijk: { + lmno: 'yes' | 'no'; + }; + }, + string, + [ + 'foo', + 'bar', + ], + ]; + }; + + type Bar = Simplify; + //=> { + // a: { + // ab: boolean; + // ac: { + // acd: string | number; + // }; + // }; + // e: [{ + // fgh: false; + // ijk: { + // lmno: 'yes' | 'no'; + // }; + // }, string, ['foo', 'bar']]; + // } + `))), + + // Compiler options overrides + exportTypeAndOption(jsdoc(fence(dedenter` + // @exactOptionalPropertyTypes: false + import type {AllExtend} from 'type-fest'; + + type A = AllExtend<[1?, 2?, 3?], number>; + //=> boolean + `))), + + // Multiple `//=>` + exportTypeAndOption(jsdoc(fence(dedenter` + const foo = {a: true, b: false, c: {d: true}} as const; + //=> { + // readonly a: true; + // readonly b: false; + // readonly c: { + // readonly d: true; + // }; + // } + const bar = ['a', 'b', 'c'] as const; + //=> readonly ['a', 'b', 'c'] + const baz = new Set(bar); + //=> Set<'a' | 'b' | 'c'> + `))), + + // Indented code blocks + exportTypeAndOption(jsdoc( + 'Note:', + dedenter` + 1. First point + \`\`\`ts + import type {Subtract} from 'type-fest'; + type A = Subtract<1, 2>; + //=> -1 + \`\`\` + 2. Second point + \`\`\`ts + import type {Sum} from 'type-fest'; + type A = Sum<1, 2>; + //=> 3 + \`\`\` + `, + )), + + // Numbers are sorted in union + exportTypeAndOption(jsdoc(fence(dedenter` + import type {IntClosedRange} from 'type-fest'; + + type ZeroToNine = IntClosedRange<0, 9>; + //=> 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 + `))), + + // Nested union are sorted + exportTypeAndOption(jsdoc(fence(dedenter` + type Test = {w: 0 | 10 | 5; x: [2 | 16 | 4]; y: {z: 3 | 27 | 9}}; + //=> {w: 0 | 5 | 10; x: [2 | 4 | 16]; y: {z: 3 | 9 | 27}} + `))), + + // Unions inside unions are sorted + exportTypeAndOption(jsdoc(fence(dedenter` + type Test = {a: 'foo' | 27 | 1 | {b: 2 | 1 | 8 | 4} | 9 | 3 | 'bar'}; + //=> {a: 'foo' | 1 | 3 | 9 | 27 | {b: 1 | 2 | 4 | 8} | 'bar'} + `))), + + // Only numbers are sorted in union, non-numbers remain unchanged + exportTypeAndOption(jsdoc(fence(dedenter` + import type {ArrayElement} from 'type-fest'; + + type Tuple1 = ArrayElement<[null, string, boolean, 1, 3, 0, -2, 4, 2, -1]>; + //=> string | boolean | -2 | -1 | 0 | 1 | 2 | 3 | 4 | null + + type Tuple2 = ArrayElement<[null, 1, 3, string, 0, -2, 4, 2, boolean, -1]>; + //=> string | boolean | -2 | -1 | 0 | 1 | 2 | 3 | 4 | null + `))), + + // Tuples are in single line + exportTypeAndOption(jsdoc(fence(dedenter` + import type {TupleOf} from 'type-fest'; + + type RGB = TupleOf<3, number>; + //=> [number, number, number] + + type TicTacToeBoard = TupleOf<3, TupleOf<3, 'X' | 'O' | null>>; + //=> [['X' | 'O' | null, 'X' | 'O' | null, 'X' | 'O' | null], ['X' | 'O' | null, 'X' | 'O' | null, 'X' | 'O' | null], ['X' | 'O' | null, 'X' | 'O' | null, 'X' | 'O' | null]] + `))), + + // Emojis are preserved + exportTypeAndOption(jsdoc(fence(dedenter` + type Pets = '🦄' | '🐶' | '🐇'; + //=> '🦄' | '🐶' | '🐇' + `))), + + // === Different types of quick info === + // Function + exportTypeAndOption(jsdoc(fence(dedenter` + declare function foo(a: string): {b: string; c: number}; + foo('a'); + //=> {b: string; c: number} + `))), + + // Variable + exportTypeAndOption(jsdoc(fence(dedenter` + const foo = 'foo'; + //=> 'foo' + + let bar = {a: 1}; + //=> {a: number} + + var baz = true; + //=> boolean + `))), + + // Type Alias + exportTypeAndOption(jsdoc(fence(dedenter` + type Foo = {a: number}; + //=> {a: number} + `))), + + // Interface + exportTypeAndOption(jsdoc(fence(dedenter` + interface Foo { foo: string; } + //=> Foo + `))), + + // Generic interface + exportTypeAndOption(jsdoc(fence(dedenter` + interface Foo { foo: T; } + //=> Foo + `))), + + // Parameter + exportTypeAndOption(jsdoc(fence(dedenter` + function foo(n: number) { + n++; + //=> number + } + `))), + + // Property + exportTypeAndOption(jsdoc(fence(dedenter` + const foo = {n: 1}; + foo + .n++; + //=> number + `))), + + // Method + exportTypeAndOption(jsdoc(fence(dedenter` + class Foo { + m() { + return 'foo'; + } + } + + const f = new Foo() + .m(); + //=> string + `))), + + // Constructor + exportTypeAndOption(jsdoc(fence(dedenter` + class Foo { + constructor() { + //=> Foo + console.log('Foo'); + } + } + `))), + + // Enum + exportTypeAndOption(jsdoc(fence(dedenter` + enum Foo {} + //=> Foo + `))), + + // Const enum + exportTypeAndOption(jsdoc(fence(dedenter` + const enum Foo { A = 1 } + //=> Foo + `))), + + // Enum Member + exportTypeAndOption(jsdoc(fence(dedenter` + enum Foo { A } + void Foo + .A; + //=> 0 + `))), + ], + invalid: [ + { + code: dedenter` + /** + \`\`\`ts + const foo = 'bar'; + //=> 'baz' + \`\`\` + */ + export type T0 = string; + `, + errors: [ + typeMismatchErrorAt({line: 4, textBeforeStart: '', target: '//=> \'baz\''}), + ], + output: dedenter` + /** + \`\`\`ts + const foo = 'bar'; + //=> 'bar' + \`\`\` + */ + export type T0 = string; + `, + }, + + // Empty `//=>` + { + code: dedenter` + /** + \`\`\`ts + type Foo = string; + //=> + \`\`\` + */ + export type T0 = string; + `, + errors: [ + typeMismatchErrorAt({line: 4, textBeforeStart: '', target: '//=>'}), + ], + output: dedenter` + /** + \`\`\`ts + type Foo = string; + //=> string + \`\`\` + */ + export type T0 = string; + `, + }, + + // No space after `//=>` + { + code: dedenter` + /** + \`\`\`ts + type Foo = string; + //=>string + \`\`\` + */ + export type T0 = string; + `, + errors: [ + typeMismatchErrorAt({line: 4, textBeforeStart: '', target: '//=>string'}), + ], + output: dedenter` + /** + \`\`\`ts + type Foo = string; + //=> string + \`\`\` + */ + export type T0 = string; + `, + }, + + // More than one space after `//=>` + { + code: dedenter` + /** + \`\`\`ts + type Foo = string; + //=> string + \`\`\` + */ + export type T0 = string; + `, + errors: [ + typeMismatchErrorAt({line: 4, textBeforeStart: '', target: '//=> string'}), + ], + output: dedenter` + /** + \`\`\`ts + type Foo = string; + //=> string + \`\`\` + */ + export type T0 = string; + `, + }, + + // No space in subsequent lines + { + code: dedenter` + /** + \`\`\`ts + const foo = {a: true, b: true, c: false, d: false, e: true} as const; + //=> { + // readonly a: true; + // readonly b: true; + // readonly c: false; + // readonly d: false; + // readonly e: true; + //} + \`\`\` + */ + export type T0 = string; + `, + errors: [ + typeMismatchErrorAt({line: 4, textBeforeStart: '', endLine: 10, textBeforeEnd: '//}'}), + ], + output: dedenter` + /** + \`\`\`ts + const foo = {a: true, b: true, c: false, d: false, e: true} as const; + //=> { + // readonly a: true; + // readonly b: true; + // readonly c: false; + // readonly d: false; + // readonly e: true; + // } + \`\`\` + */ + export type T0 = string; + `, + }, + + // Multiline replace + { + code: dedenter` + /** + \`\`\`ts + const foo = {foo: true, bar: {baz: true, qux: [true, false]}} as const; + //=> { + // foo: true; + // readonly bar: { + // readonly baz: false; + // readonly qux: [true, false]; + // }; + // } + \`\`\` + */ + export type T0 = string; + `, + errors: [ + typeMismatchErrorAt({line: 4, textBeforeStart: '', endLine: 10, textBeforeEnd: '// }'}), + ], + output: dedenter` + /** + \`\`\`ts + const foo = {foo: true, bar: {baz: true, qux: [true, false]}} as const; + //=> { + // readonly foo: true; + // readonly bar: { + // readonly baz: true; + // readonly qux: readonly [true, false]; + // }; + // } + \`\`\` + */ + export type T0 = string; + `, + }, + + // Multiline add missing lines + { + code: dedenter` + /** + \`\`\`ts + const foo = {foo: true, bar: {baz: true, qux: [true, false]}} as const; + //=> { + // readonly bar: { + // readonly qux: readonly [true, false]; + // }; + // } + \`\`\` + */ + export type T0 = string; + `, + errors: [ + typeMismatchErrorAt({line: 4, textBeforeStart: '', endLine: 8, textBeforeEnd: '// }'}), + ], + output: dedenter` + /** + \`\`\`ts + const foo = {foo: true, bar: {baz: true, qux: [true, false]}} as const; + //=> { + // readonly foo: true; + // readonly bar: { + // readonly baz: true; + // readonly qux: readonly [true, false]; + // }; + // } + \`\`\` + */ + export type T0 = string; + `, + }, + + // Multiline remove extra lines + { + code: dedenter` + /** + \`\`\`ts + const foo = {bar: {qux: [true, false]}, quux: [null, undefined]} as const; + //=> { + // readonly foo: true; + // readonly bar: { + // readonly baz: true; + // readonly qux: readonly [true, false]; + // }; + // readonly quux: readonly [null, undefined]; + // } + \`\`\` + */ + export type T0 = string; + `, + errors: [ + typeMismatchErrorAt({line: 4, textBeforeStart: '', endLine: 11, textBeforeEnd: '// }'}), + ], + output: dedenter` + /** + \`\`\`ts + const foo = {bar: {qux: [true, false]}, quux: [null, undefined]} as const; + //=> { + // readonly bar: { + // readonly qux: readonly [true, false]; + // }; + // readonly quux: readonly [null, undefined]; + // } + \`\`\` + */ + export type T0 = string; + `, + }, + + // Multi line to single line + { + code: dedenter` + /** + \`\`\`ts + const foo = [{a: 1}] as const; + //=> readonly [{ + // readonly a: 1; + // }] + \`\`\` + */ + export type T0 = string; + `, + errors: [ + typeMismatchErrorAt({line: 4, textBeforeStart: '', endLine: 6, textBeforeEnd: '// }]'}), + ], + output: dedenter` + /** + \`\`\`ts + const foo = [{a: 1}] as const; + //=> readonly [{readonly a: 1}] + \`\`\` + */ + export type T0 = string; + `, + }, + + // Compiler options overrides + { + code: dedenter` + /** + \`\`\`ts + // @exactOptionalPropertyTypes: false + type Prettify = { + [P in keyof T]: T[P]; + }; + + type T1 = Prettify<{a?: string; b?: number}>; + //=> { + // a?: string; + // b?: number; + // } + \`\`\` + */ + export type T0 = string; + `, + errors: [ + typeMismatchErrorAt({line: 9, textBeforeStart: '', endLine: 12, textBeforeEnd: '// }'}), + ], + output: dedenter` + /** + \`\`\`ts + // @exactOptionalPropertyTypes: false + type Prettify = { + [P in keyof T]: T[P]; + }; + + type T1 = Prettify<{a?: string; b?: number}>; + //=> {a?: string | undefined; b?: number | undefined} + \`\`\` + */ + export type T0 = string; + `, + }, + + // Indented code blocks + { + code: dedenter` + /** + Note: + 1. First point + \`\`\`ts + const foo = {a: true, b: false, c: {d: true}} as const; + //=> { + // a?: false; + // c?: { + // d?: false; + // }; + // } + \`\`\` + 2. Second point + \`\`\`ts + const bar = ['a', 'b', 'c'] as const; + //=> ['a', 'c'] + const baz = new Set(bar); + //=> Set + \`\`\` + */ + export type T0 = string; + `, + errors: [ + typeMismatchErrorAt({line: 6, textBeforeStart: '\t', endLine: 11, textBeforeEnd: '\t// }'}), + typeMismatchErrorAt({line: 16, textBeforeStart: '\t', target: '//=> [\'a\', \'c\']'}), + typeMismatchErrorAt({line: 18, textBeforeStart: '\t', target: '//=> Set'}), + ], + output: dedenter` + /** + Note: + 1. First point + \`\`\`ts + const foo = {a: true, b: false, c: {d: true}} as const; + //=> { + // readonly a: true; + // readonly b: false; + // readonly c: { + // readonly d: true; + // }; + // } + \`\`\` + 2. Second point + \`\`\`ts + const bar = ['a', 'b', 'c'] as const; + //=> readonly ['a', 'b', 'c'] + const baz = new Set(bar); + //=> Set<'a' | 'b' | 'c'> + \`\`\` + */ + export type T0 = string; + `, + }, + + // Multiple `//=>` + { + code: dedenter` + /** + \`\`\`ts + const foo = {a: true, b: false, c: {d: true}} as const; + //=> + const bar = ['a', 'b', 'c'] as const; + //=> + const baz = new Set(bar); + //=> + \`\`\` + */ + export type T0 = string; + `, + errors: [ + typeMismatchErrorAt({line: 4, textBeforeStart: '', target: '//=>'}), + typeMismatchErrorAt({line: 6, textBeforeStart: '', target: '//=>'}), + typeMismatchErrorAt({line: 8, textBeforeStart: '', target: '//=>'}), + ], + output: dedenter` + /** + \`\`\`ts + const foo = {a: true, b: false, c: {d: true}} as const; + //=> { + // readonly a: true; + // readonly b: false; + // readonly c: { + // readonly d: true; + // }; + // } + const bar = ['a', 'b', 'c'] as const; + //=> readonly ['a', 'b', 'c'] + const baz = new Set(bar); + //=> Set<'a' | 'b' | 'c'> + \`\`\` + */ + export type T0 = string; + `, + }, + ], +}); diff --git a/package.json b/package.json index 3718159c3..2b080f765 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "type-fest", - "version": "5.0.1", + "version": "5.4.1", "description": "A collection of essential TypeScript types", "license": "(MIT OR CC0-1.0)", "repository": "sindresorhus/type-fest", @@ -25,9 +25,9 @@ "node": ">=20" }, "scripts": { - "test:tsc": "tsc", - "test:tsd": "tsd", - "test:xo": "xo", + "test:tsc": "node --max-old-space-size=6144 ./node_modules/.bin/tsc", + "test:tsd": "node --max-old-space-size=6144 ./node_modules/.bin/tsd", + "test:xo": "node --max-old-space-size=6144 ./node_modules/.bin/xo", "test:linter": "node --test", "test": "run-p test:*" }, @@ -54,10 +54,15 @@ }, "devDependencies": { "@sindresorhus/tsconfig": "^8.0.1", + "@typescript-eslint/parser": "^8.44.0", + "eslint": "^9.35.0", + "@typescript/vfs": "^1.6.1", + "dedent": "^1.7.0", "expect-type": "^1.2.2", "npm-run-all2": "^8.0.4", "tsd": "^0.33.0", "typescript": "^5.9.2", + "typescript-eslint": "^8.47.0", "xo": "^1.2.2" }, "tsd": { diff --git a/readme.md b/readme.md index 5e25e3dbe..47e9fee30 100644 --- a/readme.md +++ b/readme.md @@ -106,6 +106,7 @@ Click the type names for complete docs. - [`Writable`](source/writable.d.ts) - Create a type that strips `readonly` from the given type. Inverse of `Readonly`. - [`WritableDeep`](source/writable-deep.d.ts) - Create a deeply mutable version of an `object`/`ReadonlyMap`/`ReadonlySet`/`ReadonlyArray` type. The inverse of `ReadonlyDeep`. Use `Writable` if you only need one level deep. - [`Merge`](source/merge.d.ts) - Merge two types into a new type. Keys of the second type overrides keys of the first type. +- [`ObjectMerge`](source/object-merge.d.ts) - Merge two object types into a new object type, where keys from the second override keys from the first. - [`MergeDeep`](source/merge-deep.d.ts) - Merge two objects or two arrays/tuples recursively into a new type. - [`MergeExclusive`](source/merge-exclusive.d.ts) - Create a type that has mutually exclusive keys. - [`OverrideProperties`](source/override-properties.d.ts) - Override only existing properties of the given type. Similar to `Merge`, but enforces that the original type has the properties you want to override. @@ -122,6 +123,7 @@ Click the type names for complete docs. - [`PartialDeep`](source/partial-deep.d.ts) - Create a deeply optional version of another type. Use [`Partial`](https://www.typescriptlang.org/docs/handbook/utility-types.html#partialtype) if you only need one level deep. - [`PartialOnUndefinedDeep`](source/partial-on-undefined-deep.d.ts) - Create a deep version of another type where all keys accepting `undefined` type are set to optional. - [`UndefinedOnPartialDeep`](source/undefined-on-partial-deep.d.ts) - Create a deep version of another type where all optional keys are set to also accept `undefined`. +- [`UnwrapPartial`](source/unwrap-partial.d.ts) - Revert the `Partial` modifier on an object type. - [`ReadonlyDeep`](source/readonly-deep.d.ts) - Create a deeply immutable version of an `object`/`Map`/`Set`/`Array` type. Use [`Readonly`](https://www.typescriptlang.org/docs/handbook/utility-types.html#readonlytype) if you only need one level deep. - [`LiteralUnion`](source/literal-union.d.ts) - Create a union type by combining primitive types and literal types without sacrificing auto-completion in IDEs for the literal type part of the union. Workaround for [Microsoft/TypeScript#29729](https://github.com/Microsoft/TypeScript/issues/29729). - [`Tagged`](source/tagged.d.ts) - Create a [tagged type](https://medium.com/@KevinBGreene/surviving-the-typescript-ecosystem-branding-and-type-tagging-6cf6e516523d) that can support [multiple tags](https://github.com/sindresorhus/type-fest/issues/665) and [per-tag metadata](https://medium.com/@ethanresnick/advanced-typescript-tagged-types-improved-with-type-level-metadata-5072fc125fcf). (This replaces the previous [`Opaque`](source/tagged.d.ts) type, which is now deprecated.) @@ -169,8 +171,8 @@ Click the type names for complete docs. - [`IntClosedRange`](source/int-closed-range.d.ts) - Generate a union of numbers (includes the start and the end). - [`ArrayIndices`](source/array-indices.d.ts) - Provides valid indices for a constant array or tuple. - [`ArrayValues`](source/array-values.d.ts) - Provides all values for a constant array or tuple. -- [`ArraySplice`](source/array-splice.d.ts) - Creates a new array type by adding or removing elements at a specified index range in the original array. -- [`ArrayTail`](source/array-tail.d.ts) - Extracts the type of an array or tuple minus the first element. +- [`ArraySplice`](source/array-splice.d.ts) - Create a new array type by adding or removing elements at a specified index range in the original array. +- [`ArrayTail`](source/array-tail.d.ts) - Extract the type of an array or tuple minus the first element. - [`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. - [`SharedUnionFields`](source/shared-union-fields.d.ts) - Create a type with shared fields from a union of object types. @@ -179,7 +181,8 @@ Click the type names for complete docs. - [`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. - [`And`](source/and.d.ts) - Returns a boolean for whether two given types are both true. -- [`Or`](source/or.d.ts) - Returns a boolean for whether either of two given types are true. +- [`Or`](source/or.d.ts) - Returns a boolean for whether either of two given types is true. +- [`Xor`](source/xor.d.ts) - Returns a boolean for whether only one of two given types is true. - [`AllExtend`](source/all-extend.d.ts) - Returns a boolean for whether every element in an array type extends another type. - [`NonEmptyTuple`](source/non-empty-tuple.d.ts) - Matches any non-empty tuple. - [`NonEmptyString`](source/non-empty-string.d.ts) - Matches any non-empty string. @@ -187,6 +190,7 @@ Click the type names for complete docs. - [`FindGlobalInstanceType`](source/find-global-type.d.ts) - Tries to find one or more types from their globally-defined constructors. - [`ConditionalSimplify`](source/conditional-simplify.d.ts) - Simplifies a type while including and/or excluding certain types from being simplified. - [`ConditionalSimplifyDeep`](source/conditional-simplify-deep.d.ts) - Recursively simplifies a type while including and/or excluding certain types from being simplified. +- [`ExclusifyUnion`](source/exclusify-union.d.ts) - Ensure mutual exclusivity in object unions by adding other members’ keys as `?: never`. ### Type Guard @@ -240,7 +244,7 @@ Click the type names for complete docs. - [`Replace`](source/replace.d.ts) - Represents a string with some or all matches replaced by a replacement. - [`StringSlice`](source/string-slice.d.ts) - Returns a string slice of a given range, just like `String#slice()`. - [`StringRepeat`](source/string-repeat.d.ts) - Returns a new string which contains the specified number of copies of a given string, just like `String#repeat()`. -- [`RemovePrefix`](source/remove-prefix.d.ts) - Removes the specified prefix from the start of a string. +- [`RemovePrefix`](source/remove-prefix.d.ts) - Remove the specified prefix from the start of a string. ### Array @@ -248,15 +252,20 @@ Click the type names for complete docs. - [`Includes`](source/includes.d.ts) - Returns a boolean for whether the given array includes the given item. - [`Join`](source/join.d.ts) - Join an array of strings and/or numbers using the given string as a delimiter. - [`ArraySlice`](source/array-slice.d.ts) - Returns an array slice of a given range, just like `Array#slice()`. -- [`LastArrayElement`](source/last-array-element.d.ts) - Extracts the type of the last element of an array. -- [`FixedLengthArray`](source/fixed-length-array.d.ts) - Create a type that represents an array of the given type and length. +- [`ArrayElement`](source/array-element.d.ts) - Extracts the element type of an array or tuple. +- [`LastArrayElement`](source/last-array-element.d.ts) - Extract the type of the last element of an array. +- [`FixedLengthArray`](source/fixed-length-array.d.ts) - Create a type that represents an array of the given type and length. The `Array` prototype methods that manipulate its length are excluded from the resulting type. - [`MultidimensionalArray`](source/multidimensional-array.d.ts) - Create a type that represents a multidimensional array of the given type and dimensions. - [`MultidimensionalReadonlyArray`](source/multidimensional-readonly-array.d.ts) - Create a type that represents a multidimensional readonly array of the given type and dimensions. - [`ReadonlyTuple`](source/readonly-tuple.d.ts) - Create a type that represents a read-only tuple of the given type and length. - [`TupleToUnion`](source/tuple-to-union.d.ts) - Convert a tuple/array into a union type of its elements. - [`UnionToTuple`](source/union-to-tuple.d.ts) - Convert a union type into an unordered tuple type of its elements. - [`TupleToObject`](source/tuple-to-object.d.ts) - Transforms a tuple into an object, mapping each tuple index to its corresponding type as a key-value pair. -- [`TupleOf`](source/tuple-of.d.ts) - Creates a tuple type of the specified length with elements of the specified type. +- [`TupleOf`](source/tuple-of.d.ts) - Create a tuple type of the specified length with elements of the specified type. +- [`SplitOnRestElement`](source/split-on-rest-element.d.ts) - Splits an array into three parts, where the first contains all elements before the rest element, the second is the [`rest`](https://www.typescriptlang.org/docs/handbook/2/objects.html#tuple-types) element itself, and the third contains all elements after the rest element. +- [`ExtractRestElement`](source/extract-rest-element.d.ts) - Extract the [`rest`](https://www.typescriptlang.org/docs/handbook/2/objects.html#tuple-types) element type from an array. +- [`ExcludeRestElement`](source/exclude-rest-element.d.ts) - Create a tuple with the [`rest`](https://www.typescriptlang.org/docs/handbook/2/objects.html#tuple-types) element removed. +- [`ArrayReverse`](source/array-reverse.d.ts) - Reverse the order of elements in a tuple type. ### Numeric @@ -286,11 +295,11 @@ Click the type names for complete docs. - [`CamelCasedProperties`](source/camel-cased-properties.d.ts) - Convert object properties to camel-case (`fooBar`). - [`CamelCasedPropertiesDeep`](source/camel-cased-properties-deep.d.ts) - Convert object properties to camel-case recursively (`fooBar`). - [`KebabCase`](source/kebab-case.d.ts) - Convert a string literal to kebab-case (`foo-bar`). -- [`KebabCasedProperties`](source/kebab-cased-properties.d.ts) - Convert a object properties to kebab-case recursively (`foo-bar`). -- [`KebabCasedPropertiesDeep`](source/kebab-cased-properties-deep.d.ts) - Convert object properties to kebab-case (`foo-bar`). -- [`PascalCase`](source/pascal-case.d.ts) - Converts a string literal to pascal-case (`FooBar`) -- [`PascalCasedProperties`](source/pascal-cased-properties.d.ts) - Converts object properties to pascal-case (`FooBar`) -- [`PascalCasedPropertiesDeep`](source/pascal-cased-properties-deep.d.ts) - Converts object properties to pascal-case (`FooBar`) +- [`KebabCasedProperties`](source/kebab-cased-properties.d.ts) - Convert object properties to kebab-case (`foo-bar`). +- [`KebabCasedPropertiesDeep`](source/kebab-cased-properties-deep.d.ts) - Convert object properties to kebab-case recursively (`foo-bar`). +- [`PascalCase`](source/pascal-case.d.ts) - Convert a string literal to pascal-case (`FooBar`). +- [`PascalCasedProperties`](source/pascal-cased-properties.d.ts) - Convert object properties to pascal-case (`FooBar`). +- [`PascalCasedPropertiesDeep`](source/pascal-cased-properties-deep.d.ts) - Convert object properties to pascal-case recursively (`FooBar`). - [`SnakeCase`](source/snake-case.d.ts) - Convert a string literal to snake-case (`foo_bar`). - [`SnakeCasedProperties`](source/snake-cased-properties.d.ts) - Convert object properties to snake-case (`foo_bar`). - [`SnakeCasedPropertiesDeep`](source/snake-cased-properties-deep.d.ts) - Convert object properties to snake-case recursively (`foo_bar`). @@ -345,6 +354,7 @@ Click the type names for complete docs. - `HomomorphicOmit` - See [`Except`](source/except.d.ts) - `IfAny`, `IfNever`, `If*` - See [`If`](source/if.d.ts) - `MaybePromise` - See [`Promisable`](source/promisable.d.ts) +- `ReadonlyTuple` - See [`TupleOf`](source/tuple-of.d.ts) ## Tips @@ -467,6 +477,8 @@ There are many advanced types most users don't know about. // NodeConfig interface. new NodeAppBuilder().config({appName: 'ToDoApp'}); ``` + + `Partial` can be reverted with [`UnwrapPartial`](source/unwrap-partial.d.ts). - [`Required`](https://www.typescriptlang.org/docs/handbook/utility-types.html#requiredtype) - Make all properties in `T` required. diff --git a/source/all-extend.d.ts b/source/all-extend.d.ts index ea0623474..f6d9f5378 100644 --- a/source/all-extend.d.ts +++ b/source/all-extend.d.ts @@ -78,18 +78,21 @@ type D = AllExtend<[true, boolean, true], true>; Note: Behaviour of optional elements depend on the `exactOptionalPropertyTypes` compiler option. When the option is disabled, the target type must include `undefined` for a successful match. ``` +// @exactOptionalPropertyTypes: true import type {AllExtend} from 'type-fest'; -// `exactOptionalPropertyTypes` enabled type A = AllExtend<[1?, 2?, 3?], number>; //=> true +``` -// `exactOptionalPropertyTypes` disabled -type B = AllExtend<[1?, 2?, 3?], number>; -//=> false +``` +// @exactOptionalPropertyTypes: false +import type {AllExtend} from 'type-fest'; + +type A = AllExtend<[1?, 2?, 3?], number>; +//=> boolean -// `exactOptionalPropertyTypes` disabled -type C = AllExtend<[1?, 2?, 3?], number | undefined>; +type B = AllExtend<[1?, 2?, 3?], number | undefined>; //=> true ``` diff --git a/source/all-union-fields.d.ts b/source/all-union-fields.d.ts index e56bc1ebb..c4239f671 100644 --- a/source/all-union-fields.d.ts +++ b/source/all-union-fields.d.ts @@ -38,14 +38,15 @@ function displayPetInfo(petInfo: Cat | Dog) { // dogType: string; // } - console.log('name: ', petInfo.name); - console.log('type: ', petInfo.type); + console.log('name:', petInfo.name); + console.log('type:', petInfo.type); // TypeScript complains about `catType` and `dogType` not existing on type `Cat | Dog`. - console.log('animal type: ', petInfo.catType ?? petInfo.dogType); + // @ts-expect-error + console.log('animal type:', petInfo.catType ?? petInfo.dogType); } -function displayPetInfo(petInfo: AllUnionFields) { +function displayPetInfoWithAllUnionFields(petInfo: AllUnionFields) { // typeof petInfo => // { // name: string; @@ -54,15 +55,15 @@ function displayPetInfo(petInfo: AllUnionFields) { // dogType?: string; // } - console.log('name: ', petInfo.name); - console.log('type: ', petInfo.type); + console.log('name:', petInfo.name); + console.log('type:', petInfo.type); // No TypeScript error. - console.log('animal type: ', petInfo.catType ?? petInfo.dogType); + console.log('animal type:', petInfo.catType ?? petInfo.dogType); } ``` -@see SharedUnionFields +@see {@link SharedUnionFields} @category Object @category Union diff --git a/source/and.d.ts b/source/and.d.ts index 013cc1713..5e6d8b759 100644 --- a/source/and.d.ts +++ b/source/and.d.ts @@ -46,6 +46,7 @@ type E = And; ``` Note: If either of the types is `never`, the result becomes `false`. + @example ``` import type {And} from 'type-fest'; @@ -73,6 +74,7 @@ type G = And; ``` @see {@link Or} +@see {@link Xor} */ export type And = AllExtend<[A, B], true>; diff --git a/source/array-element.d.ts b/source/array-element.d.ts new file mode 100644 index 000000000..58e0ef2a4 --- /dev/null +++ b/source/array-element.d.ts @@ -0,0 +1,46 @@ +import type {UnknownArray} from './unknown-array.d.ts'; + +/** +Extracts the element type of an array or tuple. + +Use-cases: +- When you need type-safe element extraction that returns `never` for non-arrays. +- When extracting element types from generic array parameters in function signatures. +- For better readability and explicit intent over using `T[number]` directly. + +Note: Returns `never` if the type is not an array. + +@example +``` +import type {ArrayElement} from 'type-fest'; + +// Arrays +type StringArray = ArrayElement; +//=> string + +// Tuples +type Tuple = ArrayElement<[1, 2, 3]>; +//=> 1 | 2 | 3 + +// Type-safe +type NotArray = ArrayElement<{a: string}>; +//=> never + +// Practical example +declare function getRandomElement(array: T): ArrayElement; + +getRandomElement(['foo', 'bar', 'baz'] as const); +//=> 'foo' | 'bar' | 'baz' +``` + +@see {@link ArrayValues} - For directly extracting values from a constant array type. +@see {@link IterableElement} - For iterables like `Set`, `Map`, and generators (not suitable for all use cases due to different inference behavior). + +@category Array +*/ +export type ArrayElement = + T extends UnknownArray + ? T[number] + : never; + +export {}; diff --git a/source/array-reverse.d.ts b/source/array-reverse.d.ts new file mode 100644 index 000000000..a3c219e86 --- /dev/null +++ b/source/array-reverse.d.ts @@ -0,0 +1,84 @@ +import type {If} from './if.d.ts'; +import type {IsArrayReadonly} from './internal/array.d.ts'; +import type {IfNotAnyOrNever, IsExactOptionalPropertyTypesEnabled} from './internal/type.d.ts'; +import type {IsOptionalKeyOf} from './is-optional-key-of.d.ts'; +import type {UnknownArray} from './unknown-array.d.ts'; + +/** +Reverse the order of elements in a tuple type. + +@example +```ts +import type {ArrayReverse} from 'type-fest'; + +type A = ArrayReverse<[string, number, boolean]>; +//=> [boolean, number, string] + +type B = ArrayReverse; +//=> readonly [...boolean[], number, string] + +type C = ArrayReverse<['foo', 'bar'] | readonly [1, 2, 3]>; +//=> ['bar', 'foo'] | readonly [3, 2, 1] + +type D = ArrayReverse; +//=> string[] + +type E = ArrayReverse<[]>; +//=> [] +``` + +Note: If the tuple contains optional elements, the result will be a union of tuples, refer to the examples below: + +@example +```ts +import type {ArrayReverse} from 'type-fest'; + +type A = ArrayReverse<[string, number, boolean?]>; +//=> [number, string] | [boolean, number, string] + +type B = ArrayReverse<[string, number?, boolean?]>; +//=> [string] | [number, string] | [boolean, number, string] + +type C = ArrayReverse<[string?, number?, boolean?]>; +//=> [] | [string] | [number, string] | [boolean, number, string] + +type D = ArrayReverse<[string, number?, ...boolean[]]>; +//=> [string] | [...boolean[], number, string] + +type E = ArrayReverse<[string?, number?, ...boolean[]]>; +//=> [] | [string] | [...boolean[], number, string] +``` + +@category Array +*/ +export type ArrayReverse = IfNotAnyOrNever extends infer Result + ? If, Readonly, Result> + : never // Should never happen + : never>; // Should never happen + +type _ArrayReverse< + TArray extends UnknownArray, + BeforeRestAcc extends UnknownArray = [], + AfterRestAcc extends UnknownArray = [], + Result extends UnknownArray = never, +> = + keyof TArray & `${number}` extends never + // Enters this branch, if `TArray` is empty (e.g., `[]`), + // or `TArray` contains no non-rest elements preceding the rest element (e.g., `[...string[]]` or `[...string[], string]`). + ? TArray extends readonly [...infer Rest, infer Last] + ? _ArrayReverse // Accumulate elements that are present after the rest element in reverse order. + : Result | [...AfterRestAcc, ...TArray, ...BeforeRestAcc] // Add the rest element between the accumulated elements. + : TArray extends readonly [(infer First)?, ...infer Rest] + ? IsOptionalKeyOf extends true + ? _ArrayReverse< + Rest, + [First | (If), ...BeforeRestAcc], // Add `| undefined` for optional elements, if `exactOptionalPropertyTypes` is disabled. + AfterRestAcc, + Result | BeforeRestAcc + > + : _ArrayReverse + : never; // Should never happen, since `readonly [(infer First)?, ...infer Rest]` is a top-type for arrays. + +export {}; diff --git a/source/array-slice.d.ts b/source/array-slice.d.ts index 045d41f33..8fac2be07 100644 --- a/source/array-slice.d.ts +++ b/source/array-slice.d.ts @@ -7,6 +7,7 @@ import type {Not, TupleMin} from './internal/index.d.ts'; import type {IsEqual} from './is-equal.d.ts'; import type {And} from './and.d.ts'; import type {ArraySplice} from './array-splice.d.ts'; +import type {IsNever} from './is-never.d.ts'; /** Returns an array slice of a given range, just like `Array#slice()`. @@ -43,14 +44,14 @@ function arraySlice< const slice = arraySlice([1, '2', {a: 3}, [4, 5]], 0, -1); -typeof slice; -//=> [1, '2', { readonly a: 3; }] +type Slice = typeof slice; +//=> [1, '2', {readonly a: 3}] -slice[2].a; +const value = slice[2].a; //=> 3 // @ts-expect-error -- TS2493: Tuple type '[1, "2", {readonly a: 3}]' of length '3' has no element at index '3'. -slice[3]; +const invalidIndexAccess = slice[3]; ``` @category Array @@ -60,13 +61,33 @@ export type ArraySlice< Start extends number = never, End extends number = never, > = Array_ extends unknown // To distributive type - ? And, IsEqual> extends true - ? Array_ - : number extends Array_['length'] - ? VariableLengthArraySliceHelper - : ArraySliceHelper extends true ? 0 : Start, IsEqual extends true ? Array_['length'] : End> + ? IsNever extends true + ? IsNever extends true + ? _ArraySlice + : End extends unknown // To distribute `End` + ? _ArraySlice + : never // Never happens + : IsNever extends true + ? Start extends unknown // To distribute `Start` + ? _ArraySlice + : never // Never happens + : Start extends unknown // To distribute `Start` + ? End extends unknown // To distribute `End` + ? _ArraySlice + : never // Never happens + : never // Never happens : never; // Never happens +type _ArraySlice< + Array_ extends readonly unknown[], + Start extends number = 0, + End extends number = Array_['length'], +> = And, IsEqual> extends true + ? Array_ + : number extends Array_['length'] + ? VariableLengthArraySliceHelper + : ArraySliceHelper extends true ? 0 : Start, IsEqual extends true ? Array_['length'] : End>; + type VariableLengthArraySliceHelper< Array_ extends readonly unknown[], Start extends number, diff --git a/source/array-splice.d.ts b/source/array-splice.d.ts index 846403632..d150d59ae 100644 --- a/source/array-splice.d.ts +++ b/source/array-splice.d.ts @@ -64,7 +64,7 @@ type SplitArrayByIndex = : SplitFixedArrayByIndex; /** -Creates a new array type by adding or removing elements at a specified index range in the original array. +Create a new array type by adding or removing elements at a specified index range in the original array. Use-case: Replace or insert items in an array type. @@ -72,17 +72,19 @@ Like [`Array#splice()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/ @example ``` +import type {ArraySplice} from 'type-fest'; + type SomeMonths0 = ['January', 'April', 'June']; -type Mouths0 = ArraySplice; -//=> type Mouths0 = ['January', 'Feb', 'March', 'April', 'June']; +type Months0 = ArraySplice; +//=> ['January', 'Feb', 'March', 'April', 'June'] type SomeMonths1 = ['January', 'April', 'June']; -type Mouths1 = ArraySplice; -//=> type Mouths1 = ['January', 'June']; +type Months1 = ArraySplice; +//=> ['January', 'June'] type SomeMonths2 = ['January', 'Foo', 'April']; -type Mouths2 = ArraySplice; -//=> type Mouths2 = ['January', 'Feb', 'March', 'April']; +type Months2 = ArraySplice; +//=> ['January', 'Feb', 'March', 'April'] ``` @category Array diff --git a/source/array-tail.d.ts b/source/array-tail.d.ts index 353ccffbf..8e874ef8d 100644 --- a/source/array-tail.d.ts +++ b/source/array-tail.d.ts @@ -3,7 +3,7 @@ import type {IfNotAnyOrNever, IsArrayReadonly} from './internal/index.d.ts'; import type {UnknownArray} from './unknown-array.d.ts'; /** -Extracts the type of an array or tuple minus the first element. +Extract the type of an array or tuple minus the first element. @example ``` diff --git a/source/arrayable.d.ts b/source/arrayable.d.ts index 15c30ff94..985392b5c 100644 --- a/source/arrayable.d.ts +++ b/source/arrayable.d.ts @@ -1,7 +1,7 @@ /** Create a type that represents either the value or an array of the value. -@see Promisable +@see {@link Promisable} @example ``` @@ -13,7 +13,7 @@ function bundle(input: string, output: Arrayable) { // … for (const output of outputList) { - console.log(`write to: ${output}`); + console.log(`write ${input} to: ${output}`); } } diff --git a/source/async-return-type.d.ts b/source/async-return-type.d.ts index 6d876a85d..63600edf1 100644 --- a/source/async-return-type.d.ts +++ b/source/async-return-type.d.ts @@ -8,14 +8,17 @@ There has been [discussion](https://github.com/microsoft/TypeScript/pull/35998) @example ```ts import type {AsyncReturnType} from 'type-fest'; -import {asyncFunction} from 'api'; + +declare function asyncFunction(): Promise<{foo: string}>; // This type resolves to the unwrapped return type of `asyncFunction`. type Value = AsyncReturnType; +//=> {foo: string} -async function doSomething(value: Value) {} +declare function doSomething(value: Value): void; -asyncFunction().then(value => doSomething(value)); +const value = await asyncFunction(); +doSomething(value); ``` @category Async diff --git a/source/asyncify.d.ts b/source/asyncify.d.ts index b265dac90..7a91a13e2 100644 --- a/source/asyncify.d.ts +++ b/source/asyncify.d.ts @@ -9,22 +9,13 @@ Use-case: You have two functions, one synchronous and one asynchronous that do t ``` import type {Asyncify} from 'type-fest'; -// Synchronous function. -function getFooSync(someArg: SomeType): Foo { - // … -} - -type AsyncifiedFooGetter = Asyncify; -//=> type AsyncifiedFooGetter = (someArg: SomeType) => Promise; - -// Same as `getFooSync` but asynchronous. -const getFooAsync: AsyncifiedFooGetter = (someArg) => { - // TypeScript now knows that `someArg` is `SomeType` automatically. - // It also knows that this function must return `Promise`. - // If you have `@typescript-eslint/promise-function-async` linter rule enabled, it will even report that "Functions that return promises must be async.". - - // … -} +// Synchronous function +type Config = {featureFlags: Record}; + +declare function loadConfigSync(path: string): Config; + +type LoadConfigAsync = Asyncify; +//=> (path: string) => Promise ``` @category Async diff --git a/source/camel-case.d.ts b/source/camel-case.d.ts index c3af7983c..55da534d0 100644 --- a/source/camel-case.d.ts +++ b/source/camel-case.d.ts @@ -1,12 +1,12 @@ import type {ApplyDefaultOptions} from './internal/index.d.ts'; -import type {Words} from './words.d.ts'; +import type {Words, WordsOptions} from './words.d.ts'; /** CamelCase options. @see {@link CamelCase} */ -export type CamelCaseOptions = { +export type CamelCaseOptions = WordsOptions & { /** Whether to preserved consecutive uppercase letter. @@ -16,6 +16,7 @@ export type CamelCaseOptions = { }; export type _DefaultCamelCaseOptions = { + splitOnNumbers: true; preserveConsecutiveUppercase: false; }; @@ -57,14 +58,14 @@ type CamelCasedProperties = { [K in keyof T as CamelCase]: T[K] }; -interface RawOptions { +type RawOptions = { 'dry-run': boolean; 'full_family_name': string; foo: number; BAR: string; QUZ_QUX: number; 'OTHER-FIELD': boolean; -} +}; const dbResult: CamelCasedProperties = { dryRun: true, @@ -72,7 +73,7 @@ const dbResult: CamelCasedProperties = { foo: 123, bar: 'foo', quzQux: 6, - otherField: false + otherField: false, }; ``` @@ -83,7 +84,7 @@ export type CamelCase = Type extend ? string extends Type ? Type : Uncapitalize ? Lowercase : Type>, + Words ? Lowercase : Type, Options>, ApplyDefaultOptions >> : Type; diff --git a/source/camel-cased-properties-deep.d.ts b/source/camel-cased-properties-deep.d.ts index 5ff9167c2..1dfa35eda 100644 --- a/source/camel-cased-properties-deep.d.ts +++ b/source/camel-cased-properties-deep.d.ts @@ -7,22 +7,22 @@ Convert object properties to camel case recursively. This can be useful when, for example, converting some API types from a different style. -@see CamelCasedProperties -@see CamelCase +@see {@link CamelCasedProperties} +@see {@link CamelCase} @example ``` import type {CamelCasedPropertiesDeep} from 'type-fest'; -interface User { +type User = { UserId: number; UserName: string; -} +}; -interface UserWithFriends { +type UserWithFriends = { UserInfo: User; UserFriends: User[]; -} +}; const result: CamelCasedPropertiesDeep = { userInfo: { diff --git a/source/camel-cased-properties.d.ts b/source/camel-cased-properties.d.ts index f8608e74b..4d197554b 100644 --- a/source/camel-cased-properties.d.ts +++ b/source/camel-cased-properties.d.ts @@ -6,17 +6,17 @@ Convert object properties to camel case but not recursively. This can be useful when, for example, converting some API types from a different style. -@see CamelCasedPropertiesDeep -@see CamelCase +@see {@link CamelCasedPropertiesDeep} +@see {@link CamelCase} @example ``` import type {CamelCasedProperties} from 'type-fest'; -interface User { +type User = { UserId: number; UserName: string; -} +}; const result: CamelCasedProperties = { userId: 1, diff --git a/source/characters.d.ts b/source/characters.d.ts index 20ea450a0..5d9bf5a3f 100644 --- a/source/characters.d.ts +++ b/source/characters.d.ts @@ -5,8 +5,10 @@ Matches any uppercase letter in the basic Latin alphabet (A-Z). ``` import type {UppercaseLetter} from 'type-fest'; -const a: UppercaseLetter = 'A'; // Valid -const b: UppercaseLetter = 'a'; // Invalid +const a: UppercaseLetter = 'A'; // Valid +// @ts-expect-error +const b: UppercaseLetter = 'a'; // Invalid +// @ts-expect-error const c: UppercaseLetter = 'AB'; // Invalid ``` @@ -22,6 +24,7 @@ Matches any lowercase letter in the basic Latin alphabet (a-z). import type {LowercaseLetter} from 'type-fest'; const a: LowercaseLetter = 'a'; // Valid +// @ts-expect-error const b: LowercaseLetter = 'A'; // Invalid ``` @@ -37,7 +40,8 @@ Matches any digit as a string ('0'-'9'). import type {DigitCharacter} from 'type-fest'; const a: DigitCharacter = '0'; // Valid -const b: DigitCharacter = 0; // Invalid +// @ts-expect-error +const b: DigitCharacter = 0; // Invalid ``` @category Type @@ -52,6 +56,7 @@ Matches any lowercase letter (a-z), uppercase letter (A-Z), or digit ('0'-'9') i import type {Alphanumeric} from 'type-fest'; const a: Alphanumeric = 'A'; // Valid +// @ts-expect-error const b: Alphanumeric = '#'; // Invalid ``` diff --git a/source/conditional-except.d.ts b/source/conditional-except.d.ts index 5b226d013..d8012c7b2 100644 --- a/source/conditional-except.d.ts +++ b/source/conditional-except.d.ts @@ -11,11 +11,11 @@ This is useful when you want to create a new type with a specific set of keys fr import type {Primitive, ConditionalExcept} from 'type-fest'; class Awesome { - name: string; - successes: number; - failures: bigint; + constructor(public name: string, public successes: number, public failures: bigint) {} - run() {} + run() { + // do something + } } type ExceptPrimitivesFromAwesome = ConditionalExcept; @@ -26,12 +26,12 @@ type ExceptPrimitivesFromAwesome = ConditionalExcept; ``` import type {ConditionalExcept} from 'type-fest'; -interface Example { +type Example = { a: string; b: string | number; c: () => void; d: {}; -} +}; type NonStringKeysOnly = ConditionalExcept; //=> {b: string | number; c: () => void; d: {}} diff --git a/source/conditional-pick-deep.d.ts b/source/conditional-pick-deep.d.ts index 26fa3dd3f..e6f12798a 100644 --- a/source/conditional-pick-deep.d.ts +++ b/source/conditional-pick-deep.d.ts @@ -22,7 +22,7 @@ type AssertCondition; //=> {a: string; c: {d: string}} @@ -83,7 +83,7 @@ type StringOrBooleanPick = ConditionalPickDeep; // c: { // d: string; // e: { -// h: string | boolean +// h: string | boolean; // }; // j: boolean; // }; diff --git a/source/conditional-pick.d.ts b/source/conditional-pick.d.ts index 7c1693465..66f908b92 100644 --- a/source/conditional-pick.d.ts +++ b/source/conditional-pick.d.ts @@ -10,11 +10,11 @@ This is useful when you want to create a new type from a specific subset of an e import type {Primitive, ConditionalPick} from 'type-fest'; class Awesome { - name: string; - successes: number; - failures: bigint; + constructor(public name: string, public successes: number, public failures: bigint) {} - run() {} + run() { + // do something + } } type PickPrimitivesFromAwesome = ConditionalPick; @@ -25,12 +25,12 @@ type PickPrimitivesFromAwesome = ConditionalPick; ``` import type {ConditionalPick} from 'type-fest'; -interface Example { +type Example = { a: string; b: string | number; c: () => void; d: {}; -} +}; type StringKeysOnly = ConditionalPick; //=> {a: string} diff --git a/source/conditional-simplify-deep.d.ts b/source/conditional-simplify-deep.d.ts index 989868e1c..d87cd0a79 100644 --- a/source/conditional-simplify-deep.d.ts +++ b/source/conditional-simplify-deep.d.ts @@ -53,15 +53,15 @@ type TypeB = { type SimplifyDeepTypeAB = ConditionalSimplifyDeep; //=> { -// foo: { +// foo: { // a: string; -// b: string; // complexType: SomeComplexType1 & SomeComplexType2; -// }; +// b: string; +// }; // } ``` -@see SimplifyDeep +@see {@link SimplifyDeep} @category Object */ export type ConditionalSimplifyDeep = Type extends ExcludeType diff --git a/source/conditional-simplify.d.ts b/source/conditional-simplify.d.ts index ec94c2d48..56667d2f6 100644 --- a/source/conditional-simplify.d.ts +++ b/source/conditional-simplify.d.ts @@ -38,7 +38,7 @@ type C = Simplify<{a: number} & {b: string}>; //=> {a: number; b: string} ``` -@see ConditionalSimplifyDeep +@see {@link ConditionalSimplifyDeep} @category Object */ export type ConditionalSimplify = Type extends ExcludeType diff --git a/source/delimiter-case.d.ts b/source/delimiter-case.d.ts index aec901e85..417f8f3ba 100644 --- a/source/delimiter-case.d.ts +++ b/source/delimiter-case.d.ts @@ -27,8 +27,8 @@ Convert a string literal to a custom string delimiter casing. This can be useful when, for example, converting a camel-cased object property to an oddly cased one. -@see KebabCase -@see SnakeCase +@see {@link KebabCase} +@see {@link SnakeCase} @example ``` @@ -45,16 +45,16 @@ type OddlyCasedProperties = { [K in keyof T as DelimiterCase]: T[K] }; -interface SomeOptions { +type SomeOptions = { dryRun: boolean; includeFile: string; foo: number; -} +}; const rawCliOptions: OddlyCasedProperties = { 'dry#run': true, 'include#file': 'bar.js', - foo: 123 + foo: 123, }; ``` diff --git a/source/delimiter-cased-properties-deep.d.ts b/source/delimiter-cased-properties-deep.d.ts index e2b28ece9..b09e852b6 100644 --- a/source/delimiter-cased-properties-deep.d.ts +++ b/source/delimiter-cased-properties-deep.d.ts @@ -8,26 +8,26 @@ Convert object properties to delimiter case recursively. This can be useful when, for example, converting some API types from a different style. -@see DelimiterCase -@see DelimiterCasedProperties +@see {@link DelimiterCase} +@see {@link DelimiterCasedProperties} @example ``` import type {DelimiterCasedPropertiesDeep} from 'type-fest'; -interface User { +type User = { userId: number; userName: string; -} +}; -interface UserWithFriends { +type UserWithFriends = { userInfo: User; userFriends: User[]; -} +}; const result: DelimiterCasedPropertiesDeep = { 'user-info': { - 'user-id': 1, + 'user-id': 1, 'user-name': 'Tom', }, 'user-friends': [ @@ -42,7 +42,7 @@ const result: DelimiterCasedPropertiesDeep = { ], }; -const splitOnNumbers: DelimiterCasedPropertiesDeep<{line1: { line2: [{ line3: string }] }}, '-', {splitOnNumbers: true}> = { +const splitOnNumbers: DelimiterCasedPropertiesDeep<{line1: {line2: [{line3: string}]}}, '-', {splitOnNumbers: true}> = { 'line-1': { 'line-2': [ { diff --git a/source/delimiter-cased-properties.d.ts b/source/delimiter-cased-properties.d.ts index b5f5674e3..842b2d206 100644 --- a/source/delimiter-cased-properties.d.ts +++ b/source/delimiter-cased-properties.d.ts @@ -7,17 +7,17 @@ Convert object properties to delimiter case but not recursively. This can be useful when, for example, converting some API types from a different style. -@see DelimiterCase -@see DelimiterCasedPropertiesDeep +@see {@link DelimiterCase} +@see {@link DelimiterCasedPropertiesDeep} @example ``` import type {DelimiterCasedProperties} from 'type-fest'; -interface User { +type User = { userId: number; userName: string; -} +}; const result: DelimiterCasedProperties = { 'user-id': 1, diff --git a/source/distributed-omit.d.ts b/source/distributed-omit.d.ts index fdaab572b..08d686218 100644 --- a/source/distributed-omit.d.ts +++ b/source/distributed-omit.d.ts @@ -25,15 +25,16 @@ type Union = A | B; type OmittedUnion = Omit; //=> {discriminant: 'A' | 'B'} -const omittedUnion: OmittedUnion = createOmittedUnion(); +declare const omittedUnion: OmittedUnion; 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'}` + // @ts-expect-error + const aValue = omittedUnion.a; + // Error: `a` is not a property of `{discriminant: 'A' | 'B'}` } ``` @@ -41,6 +42,8 @@ While `Except` solves this problem, it restricts the keys you can omit to the on @example ``` +import type {DistributedOmit} from 'type-fest'; + type A = { discriminant: 'A'; foo: string; @@ -67,17 +70,19 @@ type Union = A | B | C; type OmittedUnion = DistributedOmit; -const omittedUnion: OmittedUnion = createOmittedUnion(); +declare const omittedUnion: OmittedUnion; if (omittedUnion.discriminant === 'A') { - omittedUnion.a; - //=> OK + const aValue = omittedUnion.a; + // OK - omittedUnion.foo; - //=> Error: `foo` is not a property of `{discriminant: 'A'; a: string}` + // @ts-expect-error + const fooValue = 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}` + // @ts-expect-error + const barValue = omittedUnion.bar; + // Error: `bar` is not a property of `{discriminant: 'A'; a: string}` } ``` diff --git a/source/distributed-pick.d.ts b/source/distributed-pick.d.ts index 5441a3e5b..e40a19ebd 100644 --- a/source/distributed-pick.d.ts +++ b/source/distributed-pick.d.ts @@ -25,22 +25,25 @@ type B = { type Union = A | B; type PickedUnion = Pick; -//=> {discriminant: 'A' | 'B', foo: {bar: string} | {baz: string}} +//=> {discriminant: 'A' | 'B'; foo: {bar: string} | {baz: string}} -const pickedUnion: PickedUnion = createPickedUnion(); +declare const pickedUnion: PickedUnion; 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}'. + // @ts-expect-error + const barValue = pickedUnion.foo.bar; + // Error: Property 'bar' does not exist on type '{bar: string} | {baz: string}'. } ``` @example ``` +import type {DistributedPick} from 'type-fest'; + type A = { discriminant: 'A'; foo: { @@ -63,17 +66,19 @@ type Union = A | B; type PickedUnion = DistributedPick; -const pickedUnion: PickedUnion = createPickedUnion(); +declare const pickedUnion: PickedUnion; if (pickedUnion.discriminant === 'A') { - pickedUnion.foo.bar; - //=> OK + const barValue = pickedUnion.foo.bar; + // OK - pickedUnion.extraneous; - //=> Error: Property `extraneous` does not exist on type `Pick`. + // @ts-expect-error + const extraneousValue = 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}`. + // @ts-expect-error + const bazValue = pickedUnion.foo.baz; + // Error: `bar` is not a property of `{discriminant: 'A'; a: string}`. } ``` diff --git a/source/empty-object.d.ts b/source/empty-object.d.ts index 31e18c3e0..c23866416 100644 --- a/source/empty-object.d.ts +++ b/source/empty-object.d.ts @@ -17,8 +17,11 @@ const foo4: {} = {a: 1}; // Pass // With `EmptyObject` only the first case is valid. const bar1: EmptyObject = {}; // Pass -const bar2: EmptyObject = 42; // Fail -const bar3: EmptyObject = []; // Fail +// @ts-expect-error +const bar2: EmptyObject = []; // Fail +// @ts-expect-error +const bar3: EmptyObject = 42; // Fail +// @ts-expect-error const bar4: EmptyObject = {a: 1}; // Fail ``` @@ -36,11 +39,11 @@ Returns a `boolean` for whether the type is strictly equal to an empty plain obj import type {IsEmptyObject} from 'type-fest'; type Pass = IsEmptyObject<{}>; //=> true -type Fail = IsEmptyObject<[]>; //=> false -type Fail = IsEmptyObject; //=> false +type Fail1 = IsEmptyObject<[]>; //=> false +type Fail2 = IsEmptyObject; //=> false ``` -@see EmptyObject +@see {@link EmptyObject} @category Object */ export type IsEmptyObject = T extends EmptyObject ? true : false; diff --git a/source/entries.d.ts b/source/entries.d.ts index 8211599c2..204945514 100644 --- a/source/entries.d.ts +++ b/source/entries.d.ts @@ -16,16 +16,16 @@ For example the {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/R ``` import type {Entries} from 'type-fest'; -interface Example { +type Example = { someKey: number; -} +}; const manipulatesEntries = (examples: Entries) => examples.map(example => [ // Does some arbitrary processing on the key (with type information available) example[0].toUpperCase(), // Does some arbitrary processing on the value (with type information available) - example[1].toFixed() + example[1].toFixed(0), ]); const example: Example = {someKey: 1}; diff --git a/source/entry.d.ts b/source/entry.d.ts index 3b95b69b7..49cad1c12 100644 --- a/source/entry.d.ts +++ b/source/entry.d.ts @@ -17,16 +17,16 @@ For example the {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/R ``` import type {Entry} from 'type-fest'; -interface Example { +type Example = { someKey: number; -} +}; const manipulatesEntry = (example: Entry) => [ // Does some arbitrary processing on the key (with type information available) example[0].toUpperCase(), // Does some arbitrary processing on the value (with type information available) - example[1].toFixed(), + example[1].toFixed(0), ]; const example: Example = {someKey: 1}; diff --git a/source/exact.d.ts b/source/exact.d.ts index 34f3db56e..46d01b76b 100644 --- a/source/exact.d.ts +++ b/source/exact.d.ts @@ -1,4 +1,5 @@ -import type {ArrayElement, ObjectValue} from './internal/index.d.ts'; +import type {ObjectValue} from './internal/index.d.ts'; +import type {ArrayElement} from './array-element.d.ts'; import type {IsEqual} from './is-equal.d.ts'; import type {KeysOfUnion} from './keys-of-union.d.ts'; import type {IsUnknown} from './is-unknown.d.ts'; @@ -23,11 +24,12 @@ This is useful for function type-guarding to reject arguments with excess proper ``` type OnlyAcceptName = {name: string}; -function onlyAcceptName(arguments_: OnlyAcceptName) {} +declare function onlyAcceptName(arguments_: OnlyAcceptName): void; // TypeScript complains about excess properties when an object literal is provided. +// @ts-expect-error onlyAcceptName({name: 'name', id: 1}); -//=> `id` is excess +// `id` is excess // TypeScript does not complain about excess properties when the provided value is a variable (not an object literal). const invalidInput = {name: 'name', id: 1}; @@ -38,13 +40,14 @@ Having `Exact` allows TypeScript to reject excess properties. @example ``` -import {Exact} from 'type-fest'; +import type {Exact} from 'type-fest'; type OnlyAcceptName = {name: string}; -function onlyAcceptNameImproved>(arguments_: T) {} +declare function onlyAcceptNameImproved>(arguments_: T): void; const invalidInput = {name: 'name', id: 1}; +// @ts-expect-error onlyAcceptNameImproved(invalidInput); // Compilation error ``` diff --git a/source/except.d.ts b/source/except.d.ts index 6ad836e6e..e89092b77 100644 --- a/source/except.d.ts +++ b/source/except.d.ts @@ -66,14 +66,16 @@ type Foo = { type FooWithoutA = Except; //=> {b: string} +// @ts-expect-error const fooWithoutA: FooWithoutA = {a: 1, b: '2'}; -//=> errors: 'a' does not exist in type '{ b: string; }' +// errors: 'a' does not exist in type '{ b: string; }' type FooWithoutB = Except; -//=> {a: number} & Partial> +//=> {a: number} & Partial> +// @ts-expect-error const fooWithoutB: FooWithoutB = {a: 1, b: '2'}; -//=> errors at 'b': Type 'string' is not assignable to type 'undefined'. +// errors at 'b': Type 'string' is not assignable to type 'undefined'. // The `Omit` utility type doesn't work when omitting specific keys from objects containing index signatures. @@ -88,12 +90,12 @@ type UserData = { // `Omit` clearly doesn't behave as expected in this case: type PostPayload = Omit; -//=> type PostPayload = { [x: string]: string; [x: number]: string; } +//=> {[x: string]: string; [x: number]: string} // In situations like this, `Except` works better. // It simply removes the `email` key while preserving all the other keys. -type PostPayload = Except; -//=> type PostPayload = { [x: string]: string; name: string; role: 'admin' | 'user'; } +type PostPayloadFixed = Except; +//=> {[x: string]: string; name: string; role: 'admin' | 'user'} ``` @category Object diff --git a/source/exclude-rest-element.d.ts b/source/exclude-rest-element.d.ts new file mode 100644 index 000000000..21f0341a5 --- /dev/null +++ b/source/exclude-rest-element.d.ts @@ -0,0 +1,40 @@ +import type {SplitOnRestElement} from './split-on-rest-element.d.ts'; +import type {IsArrayReadonly} from './internal/array.d.ts'; +import type {UnknownArray} from './unknown-array.d.ts'; +import type {IfNotAnyOrNever} from './internal/type.d.ts'; + +/** +Create a tuple with the [`rest`](https://www.typescriptlang.org/docs/handbook/2/objects.html#tuple-types) element removed. + +@example +``` +import type {ExcludeRestElement} from 'type-fest'; + +type T1 = ExcludeRestElement<[number, ...string[], string, 'foo']>; +//=> [number, string, 'foo'] + +type T2 = ExcludeRestElement<[...boolean[], string]>; +//=> [string] + +type T3 = ExcludeRestElement<[...Array<'foo'>, true]>; +//=> [true] + +type T4 = ExcludeRestElement<[number, string]>; +//=> [number, string] +``` + +@see {@link ExtractRestElement} +@see {@link SplitOnRestElement} +@category Array +*/ +export type ExcludeRestElement = IfNotAnyOrNever extends infer Result + ? Result extends readonly UnknownArray[] + ? IsArrayReadonly extends true + ? Readonly<[...Result[0], ...Result[2]]> + : [...Result[0], ...Result[2]] + : never + : never +>; + +export {}; diff --git a/source/exclude-strict.d.ts b/source/exclude-strict.d.ts index bcd888c77..dc23322d9 100644 --- a/source/exclude-strict.d.ts +++ b/source/exclude-strict.d.ts @@ -6,6 +6,7 @@ For example, `ExcludeStrict` will er @example ``` // Valid Examples +import type {ExcludeStrict} from 'type-fest'; type Example1 = ExcludeStrict<{status: 'success'; data: string[]} | {status: 'error'; error: string}, {status: 'success'}>; //=> {status: 'error'; error: string} @@ -20,13 +21,16 @@ type Example3 = ExcludeStrict<{x: number; y: number} | [number, number], unknown @example ``` // Invalid Examples +import type {ExcludeStrict} from 'type-fest'; // `'xxl'` cannot exclude anything from `'xs' | 's' | 'm' | 'l' | 'xl'` +// @ts-expect-error type Example1 = ExcludeStrict<'xs' | 's' | 'm' | 'l' | 'xl', 'xl' | 'xxl'>; // ~~~~~~~~~~~~ // Error: Type "'xl' | 'xxl'" does not satisfy the constraint 'never'. // `unknown[]` cannot exclude anything from `{x: number; y: number} | {x: string; y: string}` +// @ts-expect-error type Example2 = ExcludeStrict<{x: number; y: number} | {x: string; y: string}, unknown[]>; // ~~~~~~~~~ // Error: Type 'unknown[]' does not satisfy the constraint 'never'. diff --git a/source/exclusify-union.d.ts b/source/exclusify-union.d.ts new file mode 100644 index 000000000..384243062 --- /dev/null +++ b/source/exclusify-union.d.ts @@ -0,0 +1,147 @@ +import type {If} from './if.d.ts'; +import type {IfNotAnyOrNever, MapsSetsOrArrays, NonRecursiveType} from './internal/type.d.ts'; +import type {IsUnknown} from './is-unknown.d.ts'; +import type {KeysOfUnion} from './keys-of-union.d.ts'; +import type {Simplify} from './simplify.d.ts'; + +/** +Ensure mutual exclusivity in object unions by adding other members’ keys as `?: never`. + +Use-cases: +- You want each union member to be exclusive, preventing overlapping object shapes. +- You want to safely access any property defined across the union without additional type guards. + +@example +``` +import type {ExclusifyUnion} from 'type-fest'; + +type FileConfig = { + filePath: string; +}; + +type InlineConfig = { + content: string; +}; + +declare function loadConfig1(options: FileConfig | InlineConfig): void; + +// Someone could mistakenly provide both `filePath` and `content`. +loadConfig1({filePath: './config.json', content: '{ "name": "app" }'}); // No errors + +// Use `ExclusifyUnion` to prevent that mistake. +type Config = ExclusifyUnion; +//=> { +// filePath: string; +// content?: never; +// } | { +// content: string; +// filePath?: never; +// } + +declare function loadConfig2(options: Config): void; + +// @ts-expect-error +loadConfig2({filePath: './config.json', content: '{ "name": "app" }'}); +// Error: Argument of type '{ filePath: string; content: string; }' is not assignable to parameter of type '{ filePath: string; content?: never; } | { content: string; filePath?: never; }'. + +loadConfig2({filePath: './config.json'}); // Ok + +loadConfig2({content: '{ "name": "app" }'}); // Ok +``` + +@example +``` +import type {ExclusifyUnion} from 'type-fest'; + +type CardPayment = { + amount: number; + cardNumber: string; +}; + +type PaypalPayment = { + amount: number; + paypalId: string; +}; + +function processPayment1(payment: CardPayment | PaypalPayment) { + // @ts-expect-error + const details = payment.cardNumber ?? payment.paypalId; // Cannot access `cardNumber` or `paypalId` directly +} + +type Payment = ExclusifyUnion; +//=> { +// amount: number; +// cardNumber: string; +// paypalId?: never; +// } | { +// amount: number; +// paypalId: string; +// cardNumber?: never; +// } + +function processPayment2(payment: Payment) { + const details = payment.cardNumber ?? payment.paypalId; // Ok + //=> string +} +``` + +@example +``` +import type {ExclusifyUnion} from 'type-fest'; + +type A = ExclusifyUnion<{a: string} | {b: number}>; +//=> {a: string; b?: never} | {b: number; a?: never} + +type B = ExclusifyUnion<{a: string} | {b: number} | {c: boolean}>; +//=> { +// a: string; +// b?: never; +// c?: never; +// } | { +// b: number; +// a?: never; +// c?: never; +// } | { +// c: boolean; +// a?: never; +// b?: never; +// } + +type C = ExclusifyUnion<{a: string; b: number} | {b: string; c: number}>; +//=> { +// a: string; +// b: number; +// c?: never; +// } | { +// b: string; +// c: number; +// a?: never; +// } + +type D = ExclusifyUnion<{a?: 1; readonly b: 2} | {d: 4}>; +//=> {a?: 1; readonly b: 2; d?: never} | {d: 4; a?: never; b?: never} +``` + +@category Object +@category Union +*/ +export type ExclusifyUnion = IfNotAnyOrNever, Union, + Extract extends infer SkippedMembers + ? SkippedMembers | _ExclusifyUnion> + : never + > +>; + +type _ExclusifyUnion = Union extends unknown // For distributing `Union` + ? Simplify< + Union & Partial< + Record< + Exclude, keyof Union>, + never + > + > + > + : never; // Should never happen + +export {}; diff --git a/source/extract-rest-element.d.ts b/source/extract-rest-element.d.ts new file mode 100644 index 000000000..c84eb240c --- /dev/null +++ b/source/extract-rest-element.d.ts @@ -0,0 +1,30 @@ +import type {SplitOnRestElement} from './split-on-rest-element.d.ts'; +import type {UnknownArray} from './unknown-array.d.ts'; + +/** +Extract the [`rest`](https://www.typescriptlang.org/docs/handbook/2/objects.html#tuple-types) element type from an array. + +@example +``` +import type {ExtractRestElement} from 'type-fest'; + +type T1 = ExtractRestElement<[number, ...string[], string, 'foo']>; +//=> string + +type T2 = ExtractRestElement<[...boolean[], string]>; +//=> boolean + +type T3 = ExtractRestElement<[...Array<'foo'>, true]>; +//=> 'foo' + +type T4 = ExtractRestElement<[number, string]>; +//=> never +``` + +@see {@link ExcludeRestElement} +@see {@link SplitOnRestElement} +@category Array +*/ +export type ExtractRestElement = SplitOnRestElement[1][number]; + +export {}; diff --git a/source/extract-strict.d.ts b/source/extract-strict.d.ts index 8f34d6b1d..1279f0390 100644 --- a/source/extract-strict.d.ts +++ b/source/extract-strict.d.ts @@ -1,11 +1,12 @@ /** A stricter version of {@link Extract} that ensures every member of `U` can successfully extract something from `T`. -For example, `StrictExtract` will error because `bigint` cannot extract anything from `string | number | boolean`. +For example, `ExtractStrict` will error because `bigint` cannot extract anything from `string | number | boolean`. @example ``` // Valid Examples +import type {ExtractStrict} from 'type-fest'; type Example1 = ExtractStrict<{status: 'success'; data: string[]} | {status: 'error'; error: string}, {status: 'success'}>; //=> {status: 'success'; data: string[]} @@ -20,13 +21,16 @@ type Example3 = ExtractStrict<{x: number; y: number} | [number, number], unknown @example ``` // Invalid Examples +import type {ExtractStrict} from 'type-fest'; // `'xxl'` cannot extract anything from `'xs' | 's' | 'm' | 'l' | 'xl'` +// @ts-expect-error type Example1 = ExtractStrict<'xs' | 's' | 'm' | 'l' | 'xl', 'xl' | 'xxl'>; // ~~~~~~~~~~~~ // Error: Type "'xl' | 'xxl'" does not satisfy the constraint 'never'. // `unknown[]` cannot extract anything from `{x: number; y: number} | {x: string; y: string}` +// @ts-expect-error type Example2 = ExtractStrict<{x: number; y: number} | {x: string; y: string}, unknown[]>; // ~~~~~~~~~ // Error: Type 'unknown[]' does not satisfy the constraint 'never'. diff --git a/source/find-global-type.d.ts b/source/find-global-type.d.ts index 675b67ce4..5e62fae6c 100644 --- a/source/find-global-type.d.ts +++ b/source/find-global-type.d.ts @@ -9,12 +9,12 @@ import type {FindGlobalType} from 'type-fest'; declare global { const foo: number; // let and const don't work - var bar: string; // var works + var bar: string; // var works } -type FooType = FindGlobalType<'foo'> //=> never (let/const don't work) -type BarType = FindGlobalType<'bar'> //=> string -type OtherType = FindGlobalType<'other'> //=> never (no global named 'other') +type FooType = FindGlobalType<'foo'>; //=> never (let/const don't work) +type BarType = FindGlobalType<'bar'>; //=> string +type OtherType = FindGlobalType<'other'>; //=> never (no global named 'other') ``` @category Utilities @@ -48,7 +48,9 @@ declare global { class Foo {} // interface + constructor style works - interface Bar {} + interface Bar { + bar: string; + } var Bar: new () => Bar; // Not let or const } diff --git a/source/fixed-length-array.d.ts b/source/fixed-length-array.d.ts index 5cb5297c0..eaa0d7283 100644 --- a/source/fixed-length-array.d.ts +++ b/source/fixed-length-array.d.ts @@ -1,45 +1,97 @@ +import type {Except} from './except.d.ts'; +import type {TupleOf} from './tuple-of.d.ts'; + /** Methods to exclude. */ type ArrayLengthMutationKeys = 'splice' | 'push' | 'pop' | 'shift' | 'unshift'; /** -Create a type that represents an array of the given type and length. The array's length and the `Array` prototype methods that manipulate its length are excluded in the resulting type. +Create a type that represents an array of the given type and length. The `Array` prototype methods that manipulate its length are excluded from the resulting type. + +The problem with the built-in tuple type is that it allows mutating methods like `push`, `pop` etc, which can cause issues, like in the following example: + +@example +``` +const color: [number, number, number] = [255, 128, 64]; + +function toHex([r, g, b]: readonly [number, number, number]) { + return `#${r.toString(16)}${g.toString(16)}${b.toString(16)}`; +} -Please participate in [this issue](https://github.com/microsoft/TypeScript/issues/26223) if you want to have a similar type built into TypeScript. +color.pop(); // Allowed + +console.log(toHex(color)); // Compiles fine, but fails at runtime since index `2` no longer contains a `number`. +``` + +`ArrayLengthMutationKeys` solves this problem by excluding methods like `push`, `pop` etc from the resulting type. + +@example +``` +import type {FixedLengthArray} from 'type-fest'; + +const color: FixedLengthArray = [255, 128, 64]; + +// @ts-expect-error +color.pop(); +// Error: Property 'pop' does not exist on type 'FixedLengthArray'. +``` Use-cases: - Declaring fixed-length tuples or arrays with a large number of items. -- Creating a range union (for example, `0 | 1 | 2 | 3 | 4` from the keys of such a type) without having to resort to recursive types. - Creating an array of coordinates with a static length, for example, length of 3 for a 3D vector. -Note: This type does not prevent out-of-bounds access. Prefer `ReadonlyTuple` unless you need mutability. - @example ``` import type {FixedLengthArray} from 'type-fest'; -type FencingTeam = FixedLengthArray; +let color: FixedLengthArray = [255, 128, 64]; + +const red = color[0]; +//=> number +const green = color[1]; +//=> number +const blue = color[2]; +//=> number + +// @ts-expect-error +const alpha = color[3]; +// Error: Property '3' does not exist on type 'FixedLengthArray'. + +// You can write to valid indices. +color[0] = 128; +color[1] = 64; +color[2] = 32; + +// But you cannot write to out-of-bounds indices. +// @ts-expect-error +color[3] = 0.5; +// Error: Property '3' does not exist on type 'FixedLengthArray'. + +// @ts-expect-error +color.push(0.5); +// Error: Property 'push' does not exist on type 'FixedLengthArray'. + +// @ts-expect-error +color = [0, 128, 255, 0.5]; +// Error: Type '[number, number, number, number]' is not assignable to type 'FixedLengthArray'. Types of property 'length' are incompatible. -const guestFencingTeam: FencingTeam = ['Josh', 'Michael', 'Robert']; +// @ts-expect-error +color.length = 4; +// Error: Cannot assign to 'length' because it is a read-only property. -const homeFencingTeam: FencingTeam = ['George', 'John']; -//=> error TS2322: Type string[] is not assignable to type 'FencingTeam' +function toHex([r, g, b]: readonly [number, number, number]) { + return `#${r.toString(16)}${g.toString(16)}${b.toString(16)}`; +} -guestFencingTeam.push('Sam'); -//=> error TS2339: Property 'push' does not exist on type 'FencingTeam' +console.log(toHex(color)); // `FixedLengthArray` is assignable to `readonly [number, number, number]`. ``` @category Array -@see ReadonlyTuple */ -export type FixedLengthArray = Pick< - ArrayPrototype, - Exclude -> & { - [index: number]: Element; - [Symbol.iterator]: () => IterableIterator; - readonly length: Length; -}; +export type FixedLengthArray = + Except, ArrayLengthMutationKeys | number | 'length'> + & {readonly length: Length} + & (number extends Length ? {[n: number]: Element} : {}); // Add `number` index signature only for non-tuple arrays. export {}; diff --git a/source/get.d.ts b/source/get.d.ts index dbbd31973..e1077ded1 100644 --- a/source/get.d.ts +++ b/source/get.d.ts @@ -58,10 +58,10 @@ Splits a dot-prop style path into a tuple comprised of the properties in the pat @example ``` -ToPath<'foo.bar.baz'> +type A = ToPath<'foo.bar.baz'>; //=> ['foo', 'bar', 'baz'] -ToPath<'foo[0].bar.baz'> +type B = ToPath<'foo[0].bar.baz'>; //=> ['foo', '0', 'bar', 'baz'] ``` */ @@ -84,10 +84,10 @@ Returns true if `LongString` is made up out of `Substring` repeated 0 or more ti @example ``` -ConsistsOnlyOf<'aaa', 'a'> //=> true -ConsistsOnlyOf<'ababab', 'ab'> //=> true -ConsistsOnlyOf<'aBa', 'a'> //=> false -ConsistsOnlyOf<'', 'a'> //=> true +type A = ConsistsOnlyOf<'aaa', 'a'>; //=> true +type B = ConsistsOnlyOf<'ababab', 'ab'>; //=> true +type C = ConsistsOnlyOf<'aBa', 'a'>; //=> false +type D = ConsistsOnlyOf<'', 'a'>; //=> true ``` */ type ConsistsOnlyOf = @@ -168,38 +168,43 @@ Use-case: Retrieve a property from deep inside an API response or some other com @example ``` import type {Get} from 'type-fest'; -import * as lodash from 'lodash'; -const get = (object: BaseType, path: Path): Get => - lodash.get(object, path); +declare function get(object: BaseType, path: Path): Get; -interface ApiResponse { +type ApiResponse = { hits: { hits: Array<{ - _id: string + _id: string; _source: { name: Array<{ - given: string[] - family: string - }> - birthDate: string - } - }> - } -} - -const getName = (apiResponse: ApiResponse) => - get(apiResponse, 'hits.hits[0]._source.name'); - //=> Array<{given: string[]; family: string}> | undefined + given: string[]; + family: string; + }>; + birthDate: string; + }; + }>; + }; +}; + +const getName = (apiResponse: ApiResponse) => get(apiResponse, 'hits.hits[0]._source.name'); +//=> (apiResponse: ApiResponse) => { +// given: string[]; +// family: string; +// }[] | undefined // Path also supports a readonly array of strings -const getNameWithPathArray = (apiResponse: ApiResponse) => - get(apiResponse, ['hits','hits', '0', '_source', 'name'] as const); - //=> Array<{given: string[]; family: string}> | undefined +const getNameWithPathArray = (apiResponse: ApiResponse) => get(apiResponse, ['hits', 'hits', '0', '_source', 'name']); +//=> (apiResponse: ApiResponse) => { +// given: string[]; +// family: string; +// }[] | undefined // Non-strict mode: -Get //=> string -Get, 'foo', {strict: true}> // => string +type A = Get; +//=> string + +type B = Get, 'foo', {strict: true}>; +//=> string | undefined ``` @category Object diff --git a/source/global-this.d.ts b/source/global-this.d.ts index 9170c18ba..bf3633921 100644 --- a/source/global-this.d.ts +++ b/source/global-this.d.ts @@ -13,7 +13,8 @@ type ExtraGlobals = GlobalThis & { readonly GLOBAL_TOKEN: string; }; -(globalThis as ExtraGlobals).GLOBAL_TOKEN; +const globalToken = (globalThis as ExtraGlobals).GLOBAL_TOKEN; +//=> string ``` @category Type diff --git a/source/greater-than-or-equal.d.ts b/source/greater-than-or-equal.d.ts index b99d36ab5..87c7b4368 100644 --- a/source/greater-than-or-equal.d.ts +++ b/source/greater-than-or-equal.d.ts @@ -7,18 +7,24 @@ Returns a boolean for whether a given number is greater than or equal to another ``` import type {GreaterThanOrEqual} from 'type-fest'; -GreaterThanOrEqual<1, -5>; +type A = GreaterThanOrEqual<1, -5>; //=> true -GreaterThanOrEqual<1, 1>; +type B = GreaterThanOrEqual<1, 1>; //=> true -GreaterThanOrEqual<1, 5>; +type C = GreaterThanOrEqual<1, 5>; //=> false ``` */ export type GreaterThanOrEqual = number extends A | B ? never - : A extends B ? true : GreaterThan; + : A extends number // For distributing `A` + ? B extends number // For distributing `B` + ? A extends B + ? true + : GreaterThan + : never // Should never happen + : never; // Should never happen export {}; diff --git a/source/greater-than.d.ts b/source/greater-than.d.ts index 9de7b7096..4a13584c8 100644 --- a/source/greater-than.d.ts +++ b/source/greater-than.d.ts @@ -11,13 +11,13 @@ Returns a boolean for whether a given number is greater than another number. ``` import type {GreaterThan} from 'type-fest'; -GreaterThan<1, -5>; +type A = GreaterThan<1, -5>; //=> true -GreaterThan<1, 1>; +type B = GreaterThan<1, 1>; //=> false -GreaterThan<1, 5>; +type C = GreaterThan<1, 5>; //=> false ``` */ diff --git a/source/has-optional-keys.d.ts b/source/has-optional-keys.d.ts index 75a15542d..cc496d01c 100644 --- a/source/has-optional-keys.d.ts +++ b/source/has-optional-keys.d.ts @@ -12,8 +12,8 @@ import type {HasOptionalKeys, OptionalKeysOf} from 'type-fest'; type UpdateService = { removeField: HasOptionalKeys extends true ? (field: OptionalKeysOf) => Promise - : never -} + : never; +}; ``` @category Utilities diff --git a/source/has-readonly-keys.d.ts b/source/has-readonly-keys.d.ts index 3361a8285..64d7e6445 100644 --- a/source/has-readonly-keys.d.ts +++ b/source/has-readonly-keys.d.ts @@ -12,8 +12,8 @@ import type {HasReadonlyKeys, ReadonlyKeysOf} from 'type-fest'; type UpdateService = { removeField: HasReadonlyKeys extends true ? (field: ReadonlyKeysOf) => Promise - : never -} + : never; +}; ``` @category Utilities diff --git a/source/has-required-keys.d.ts b/source/has-required-keys.d.ts index 902870045..c210d0d4f 100644 --- a/source/has-required-keys.d.ts +++ b/source/has-required-keys.d.ts @@ -16,40 +16,40 @@ type GeneratorOptions