feat(typescript-types): Fully rewrite KeyPaths<T,O,F>

Rewrite KeyPaths to eleminate 'Too deep instantiations' error of
TypeScript.
This commit is contained in:
T. R. Bernstein
2025-07-22 11:49:08 +02:00
parent 0e7238998d
commit 434d91ff9d
18 changed files with 163 additions and 75 deletions

View File

@@ -18,10 +18,26 @@ The types included in this library are categorized by their purpose.
[`BuiltIns`]: src/built-ins.ts [`BuiltIns`]: src/built-ins.ts
[`NonContainerType`]: src/non-container-type.ts [`NonContainerType`]: src/non-container-type.ts
#### Boolean Operator Types
| Type | Description |
| --------------- | -------------------------------------------------------------------------- |
| [`And<A, B>`][] | `true` if `A` and `B` (both extend `boolean`) are both `true`. |
| [`Not<A>`][] | `true` if `A` (extends `boolean`) is `false` and vice-versa. |
| [`Or<A>`][] | `true` if either `A` or `B` (both extend `boolean`) is `true`. |
| [`Xor<A, B>`][] | `true` if `A` and `B` (both extend `boolean`) are not both `false`/`true`. |
[`And<A, B>`]: src/and.ts
[`Not<A>`]: src/not.ts
[`Or<A>`]: src/or.ts
[`Xor<A, B>`]: src/xor.ts
#### Test Types #### Test Types
| Type | Description | | Type | Description |
| --------------------------------------- | --------------------------------------------------------------------------------------------------------- | | --------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| [`Extends<A, B>`][] | `true` if `A` extends `B`, `false` otherwise[^extends_remark]. |
| [`ExtendsExactly<A, B>`][] | `true` if `A` extends `B`, `false` otherwise[^extends-exactly_remark]. |
| [`IsAny<T>`][] | `true` if `T` is `any`, `false` otherwise (`null`, `undefined` also yield `false`) | | [`IsAny<T>`][] | `true` if `T` is `any`, `false` otherwise (`null`, `undefined` also yield `false`) |
| [`IsNever<T>`][] | `true` if `T` is `never`, `false` otherwise (`null`, `undefined`, `any` also yield `false`) | | [`IsNever<T>`][] | `true` if `T` is `never`, `false` otherwise (`null`, `undefined`, `any` also yield `false`) |
| [`IsUndefined<T>`][] | `true` if `T` is `undefined`, `false` otherwise (`null`, `never`, `any` also yield `false`). | | [`IsUndefined<T>`][] | `true` if `T` is `undefined`, `false` otherwise (`null`, `never`, `any` also yield `false`). |
@@ -30,10 +46,12 @@ The types included in this library are categorized by their purpose.
| [`IsKeyOf<T, K>`][] | `true` if `K` is a key of `T`, `false` otherwise. If `T` is `any`, any `K` but `never` will yield `true`. | | [`IsKeyOf<T, K>`][] | `true` if `K` is a key of `T`, `false` otherwise. If `T` is `any`, any `K` but `never` will yield `true`. |
| [`IsEmptyString<S>`][] | `true` if `S` is the empty string `''`, `false` otherwise.[^is-empty-string_remark] | | [`IsEmptyString<S>`][] | `true` if `S` is the empty string `''`, `false` otherwise.[^is-empty-string_remark] |
[^if_remark]: If `boolean` is passed as `Test` the return value is a union of both branches, i.e. `TrueBranch | FalseBranch`. [^extends_remark]: The result may be `boolean` (aka `true | false`) if `A` or `B` is a union type and the union members evaluate to different boolean results.
[^is-empty-string_remark]: If `T` is `any` will yield `true`, as it is taken to be `any` string. [^extends-exactly_remark]: The extends check is done non-distributively in case `A` or `B` is a union type.
[`Extends<A, B>`]: src/extends.ts
[`ExtendsExactly<A, B>`]: src/extends-exactly.ts
[`IsAny<T>`]: src/is-any.ts [`IsAny<T>`]: src/is-any.ts
[`IsNever<T>`]: src/is-never.ts [`IsNever<T>`]: src/is-never.ts
[`IsUndefined<T>`]: src/is-undefined.ts [`IsUndefined<T>`]: src/is-undefined.ts
@@ -71,7 +89,9 @@ The types included in this library are categorized by their purpose.
#### Combination Types #### Combination Types
| Type | Description | | Type | Description |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| [`Assign<Shape, Defaults, Obj>`][] | Return a type with the structure of `Shape` and value types from `Obj` or `Default` for missing optional keys in `Obj`. | | [`Assign<Shape, Defaults, Obj>`][] | Return a type with the structure of `Shape` and value types from `Obj` or `Default` for missing optional keys in `Obj`. |
| [`Concat<Prefix, Suffix, Separator>`][] | Concatenate `Prefix` and `Suffix` and - if both are non empty strings - separate them using `Separator`. |
[`Assign<Shape, Defaults, Obj>`]: src/assign.ts [`Assign<Shape, Defaults, Obj>`]: src/assign.ts
[`Concat<Prefix, Suffix, Separator>`]: src/concat.ts

View File

@@ -0,0 +1,5 @@
export type And<A extends boolean, B extends boolean> = A extends true
? B extends true
? true
: false
: false

View File

@@ -0,0 +1,9 @@
import type { If } from './if.js'
import type { Or } from './or.js'
import type { IsEmptyString } from './is-empty-string.js'
export type Concat<Prefix extends string, Suffix extends string, Separator extends string = ''> = If<
Or<IsEmptyString<Prefix>, IsEmptyString<Suffix>>,
`${Prefix}${Suffix}`,
`${Prefix}${Separator}${Suffix}`
>

View File

@@ -0,0 +1 @@
export type ExtendsExactly<A, B> = [A] extends [B] ? true : false

View File

@@ -0,0 +1 @@
export type Extends<A, B> = A extends B ? true : false

View File

@@ -1,5 +1,8 @@
export type * from './and.js'
export type * from './assign.js' export type * from './assign.js'
export type * from './built-ins.js' export type * from './built-ins.js'
export type * from './extends-exactly.js'
export type * from './extends.js'
export type * from './get.js' export type * from './get.js'
export type * from './if.js' export type * from './if.js'
export type * from './is-any.js' export type * from './is-any.js'
@@ -14,3 +17,4 @@ export type * from './optional-keys-of.js'
export type * from './pick-assignable.js' export type * from './pick-assignable.js'
export type * from './primitive.js' export type * from './primitive.js'
export type * from './simplify.js' export type * from './simplify.js'
export type * from './xor.js'

View File

@@ -1,55 +1,68 @@
import type { If } from './if.js' import type { Extends } from './extends.js'
import type { IsEmptyString } from './is-empty-string.js' import type { Concat } from './concat.js'
import type { IsUndefined } from './is-undefined.js'
import type { Simplify } from './simplify.js'
import type { Assign } from './assign.js' import type { Assign } from './assign.js'
import type { NonContainerType } from './non-container-type.js'
interface KeyPaths_Options { export interface KeyPaths_Options {
separator?: string separator?: string
leavesOnly?: boolean leavesOnly?: boolean
invertFilter?: boolean invertFilter?: boolean
} }
type RequiredOptions = Required<KeyPaths_Options>
interface KeyPaths_DefaultOptions { interface KeyPaths_DefaultOptions {
separator: '.' separator: '.'
leavesOnly: true leavesOnly: true
invertFilter: false invertFilter: false
} }
type GetPrefixedKey< type ExtendsFilter<Obj, Key extends keyof Obj, Filter> =
Parent extends string, Extends<Obj[Key], Filter> extends false ? false : true
Key extends string,
Separator extends string
> = `${If<IsEmptyString<Parent>, '', `${Parent}${Separator}`>}${Key}`
type PrefixIfNot<Cond, Prefix, T> = If<Cond, T, Prefix | T> type IncludeElement<Obj, Key extends keyof Obj, Filter> = Obj extends never
? false
: Obj[Key] extends never
? false
: unknown extends Obj[Key]
? false
: ExtendsFilter<Obj, Key, Filter> extends false
? true
: false
type KeyPathOf<Obj, Options extends Required<KeyPaths_Options>, Parent extends string, Filter = never> = type AdaptKeyForOptionals<Obj, Key extends keyof Obj> = Key extends number | string
Obj extends Record<PropertyKey, unknown> ? undefined extends Obj[Key]
? PrefixIfNot<Options['leavesOnly'], Parent, KeyPathsOfStringKeys<Obj, Options, Filter, Parent>> ? Obj[Key] extends undefined
: If<IsUndefined<Obj>, never, Parent> ? `${Key}`
: `${Key}?`
: `${Key}`
: never
type NonUndefined<Obj> = Obj extends undefined ? never : Obj
type KeysOf<Obj> = Obj extends undefined ? never : Exclude<keyof Obj, keyof any[]>
type KeyPathsOfStringKeys< type KeyPathsOfStringKeys<
Obj extends object, Obj,
Options extends Required<KeyPaths_Options>, Key extends keyof Obj,
Options extends RequiredOptions,
Filter, Filter,
Parent extends string = '' Parent extends string = ''
> = { > = Key extends Key
[Key in keyof Obj & string]: Obj[Key] extends Filter ? IncludeElement<Obj, Key, Filter> extends true
? If< ? Obj[Key] extends NonContainerType
Options['invertFilter'], ? Concat<Parent, AdaptKeyForOptionals<Obj, Key>, Options['separator']>
KeyPathOf<Obj[Key], Options, GetPrefixedKey<Parent, Key, Options['separator']>, Filter>, : KeyPathsOfStringKeys<
never NonUndefined<Obj[Key]>,
KeysOf<Obj[Key]>,
Options,
Filter,
Concat<Parent, AdaptKeyForOptionals<Obj, Key>, Options['separator']>
> >
: If< : never
Options['invertFilter'], : never
never,
KeyPathOf<Obj[Key], Options, GetPrefixedKey<Parent, Key, Options['separator']>, Filter>
>
}[keyof Obj & string]
export type KeyPaths< export type KeyPaths<
Obj extends object, Obj,
Options extends KeyPaths_Options = {}, Options extends KeyPaths_Options = {},
Filter = null | undefined Filter = null | undefined
> = Simplify<KeyPathsOfStringKeys<Obj, Assign<KeyPaths_Options, KeyPaths_DefaultOptions, Options>, Filter>> > = KeyPathsOfStringKeys<Obj, keyof Obj, Assign<KeyPaths_Options, KeyPaths_DefaultOptions, Options>, Filter>

View File

@@ -0,0 +1 @@
export type Not<A extends boolean> = A extends true ? false : true

View File

@@ -0,0 +1 @@
export type Or<A extends boolean, B extends boolean> = A extends true ? true : B extends true ? true : false

View File

@@ -0,0 +1 @@
export type Xor<A extends boolean, B extends Boolean> = [A] extends [B] ? false : true

View File

@@ -0,0 +1,7 @@
import type { And } from '@/and.js'
import { expect } from 'tstyche'
expect<And<true, true>>().type.toBe<true>()
expect<And<true, false>>().type.toBe<false>()
expect<And<false, true>>().type.toBe<false>()
expect<And<false, false>>().type.toBe<false>()

View File

@@ -0,0 +1,8 @@
import type { Concat } from '@/concat.js'
import { expect } from 'tstyche'
expect<Concat<'', '', ''>>().type.toBe<''>()
expect<Concat<'Parent', '', ''>>().type.toBe<'Parent'>()
expect<Concat<'Parent', 'Child', ''>>().type.toBe<'ParentChild'>()
expect<Concat<'Parent', 'Child', '.'>>().type.toBe<'Parent.Child'>()
expect<Concat<'', 'Child', '.'>>().type.toBe<'Child'>()

View File

@@ -0,0 +1,7 @@
import type { ExtendsExactly } from '@/extends-exactly.js'
import { expect } from 'tstyche'
expect<ExtendsExactly<true, true>>().type.toBe<true>()
expect<ExtendsExactly<true, boolean>>().type.toBe<true>()
expect<ExtendsExactly<boolean, true>>().type.toBe<false>()
expect<ExtendsExactly<false, true>>().type.toBe<false>()

View File

@@ -0,0 +1,10 @@
import type { Extends } from '@/extends.js'
import { expect } from 'tstyche'
expect<Extends<true, true>>().type.toBe<true>()
expect<Extends<true, boolean>>().type.toBe<true>()
expect<Extends<boolean, true>>().type.toBe<boolean>()
expect<Extends<false, true>>().type.toBe<false>()
expect<Extends<false, never>>().type.toBe<false>()
expect<Extends<null, never>>().type.toBe<false>()
expect<Extends<undefined, never>>().type.toBe<false>()

View File

@@ -4,50 +4,31 @@ import { expect } from 'tstyche'
interface ExampleObject { interface ExampleObject {
nullvalue: null nullvalue: null
simplevalue: string simplevalue: string
optionalKey?: {
nullvalue: null
simplevalue: string
}
unknownKey: unknown unknownKey: unknown
neverKey: never neverKey: never
config: { undefinedKey: undefined
nullvalue: null undefinedOptionalKey?: undefined
simplevalue: string optionalKey?: string
} arrayKey: [{ name: string; age: number }, 1]
optionalArrayKey?: [{ name: string }, { age?: number }]
} }
type AllKeys = type KeysWhenFilterIsSetToNever =
| 'nullvalue' | 'nullvalue'
| 'simplevalue' | 'simplevalue'
| 'optionalKey.nullvalue' | 'undefinedKey'
| 'optionalKey.simplevalue' | 'undefinedOptionalKey'
| 'unknownKey' | 'optionalKey?'
| 'config.nullvalue' | 'arrayKey.0.name'
| 'config.simplevalue' | 'arrayKey.0.age'
| 'arrayKey.1'
| 'optionalArrayKey?.0.name'
| 'optionalArrayKey?.1.age?'
// type T = any extends never ? true : false
// type A = KeyPaths<ExampleObject, {}, never>
expect<KeyPaths<ExampleObject>>().type.toBe< expect<KeyPaths<ExampleObject>>().type.toBe<
'simplevalue' | 'optionalKey.simplevalue' | 'unknownKey' | 'config.simplevalue' 'simplevalue' | 'arrayKey.0.name' | 'arrayKey.0.age' | 'arrayKey.1'
>() >()
expect<KeyPaths<ExampleObject, {}, any>>().type.toBeAssignableTo<never>() expect<KeyPaths<ExampleObject, {}, never>>().type.toBe<KeysWhenFilterIsSetToNever>()
expect<KeyPaths<ExampleObject, {}, unknown>>().type.toBeAssignableTo<never>() expect<KeyPaths<ExampleObject, {}, any>>().type.toBeAssignableWith<never>()
expect<KeyPaths<ExampleObject, {}, never>>().type.toBe<AllKeys>()
expect<KeyPaths<ExampleObject, { invertFilter: true }, any>>().type.toBe<AllKeys>()
expect<KeyPaths<ExampleObject, {}, null>>().type.toBe<
'simplevalue' | 'optionalKey.simplevalue' | 'unknownKey' | 'config.simplevalue'
>()
expect<KeyPaths<ExampleObject, {}, string>>().type.toBe<
'nullvalue' | 'optionalKey.nullvalue' | 'unknownKey' | 'config.nullvalue'
>()
expect<KeyPaths<ExampleObject, { separator: '-' }>>().type.toBe<
'simplevalue' | 'optionalKey-simplevalue' | 'unknownKey' | 'config-simplevalue'
>()
expect<KeyPaths<ExampleObject, { leavesOnly: false }>>().type.toBe<
'simplevalue' | 'optionalKey' | 'optionalKey.simplevalue' | 'unknownKey' | 'config' | 'config.simplevalue'
>()
expect<KeyPaths<any, {}, any>>().type.toBeAssignableTo<never>()
expect<KeyPaths<never, {}, any>>().type.toBeAssignableTo<never>()
expect<KeyPaths<never, {}, never>>().type.toBeAssignableTo<never>()
expect<KeyPaths<any, {}, never>>().type.toBe<unknown>()
expect<KeyPaths<any, {}, string>>().type.toBeAssignableTo<unknown>()

View File

@@ -0,0 +1,5 @@
import type { Not } from '@/not.js'
import { expect } from 'tstyche'
expect<Not<true>>().type.toBe<false>()
expect<Not<false>>().type.toBe<true>()

View File

@@ -0,0 +1,7 @@
import type { Or } from '@/or.js'
import { expect } from 'tstyche'
expect<Or<true, true>>().type.toBe<true>()
expect<Or<true, false>>().type.toBe<true>()
expect<Or<false, true>>().type.toBe<true>()
expect<Or<false, false>>().type.toBe<false>()

View File

@@ -0,0 +1,7 @@
import type { Xor } from '@/xor.js'
import { expect } from 'tstyche'
expect<Xor<true, true>>().type.toBe<false>()
expect<Xor<true, false>>().type.toBe<true>()
expect<Xor<false, true>>().type.toBe<true>()
expect<Xor<false, false>>().type.toBe<false>()