From e40e6407bb26a95bfe7b1dfdc1fca75809bba155 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Fri, 12 Feb 2021 11:24:51 -0500 Subject: [PATCH] Add `Get` type (#153) Co-authored-by: Sindre Sorhus --- package.json | 1 + readme.md | 1 + source/utilities.d.ts | 2 + test-d/camel-case.ts | 3 +- test-d/get.ts | 81 ++++++++++++++++++++++++++ ts41/camel-case.d.ts | 10 +--- ts41/get.d.ts | 131 ++++++++++++++++++++++++++++++++++++++++++ ts41/utilities.d.ts | 8 +++ 8 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 test-d/get.ts create mode 100644 ts41/get.d.ts create mode 100644 ts41/utilities.d.ts diff --git a/package.json b/package.json index 396f6d0e1..a6427ad0b 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ ], "devDependencies": { "@sindresorhus/tsconfig": "~0.7.0", + "expect-type": "^0.11.0", "tsd": "^0.14.0", "typescript": "^4.1.3", "xo": "^0.36.1" diff --git a/readme.md b/readme.md index 176eaea90..4fae2d385 100644 --- a/readme.md +++ b/readme.md @@ -114,6 +114,7 @@ Click the type names for complete docs. - [`PascalCase`](ts41/pascal-case.d.ts) – Converts a string literal to pascal-case (`FooBar`) - [`SnakeCase`](ts41/snake-case.d.ts) – Convert a string literal to snake-case (`foo_bar`). - [`DelimiterCase`](ts41/delimiter-case.d.ts) – Convert a string literal to a custom string delimiter casing. +- [`Get`](ts41/get.d.ts) - Get a deeply-nested property from an object using a key path, like [Lodash's `.get()`](https://lodash.com/docs/latest#get) function. ### Miscellaneous diff --git a/source/utilities.d.ts b/source/utilities.d.ts index 0bd75e667..8d60ccde0 100644 --- a/source/utilities.d.ts +++ b/source/utilities.d.ts @@ -1,3 +1,5 @@ export type UpperCaseCharacters = 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z'; export type WordSeparators = '-' | '_' | ' '; + +export type StringDigit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'; diff --git a/test-d/camel-case.ts b/test-d/camel-case.ts index eca439855..9431d7c42 100644 --- a/test-d/camel-case.ts +++ b/test-d/camel-case.ts @@ -1,4 +1,5 @@ -import {Split, CamelCase} from '../ts41/camel-case'; +import {CamelCase} from '../ts41/camel-case'; +import {Split} from '../ts41/utilities'; import {expectType, expectAssignable} from 'tsd'; // Split diff --git a/test-d/get.ts b/test-d/get.ts new file mode 100644 index 000000000..9422e7070 --- /dev/null +++ b/test-d/get.ts @@ -0,0 +1,81 @@ +import {Get} from '../ts41/get'; +import {expectTypeOf} from 'expect-type'; + +declare const get: (object: ObjectType, path: Path) => Get; + +interface ApiResponse { + hits: { + hits: Array<{ + _id: string; + _source: { + name: Array<{ + given: string[]; + family: string; + }>; + birthDate: string; + }; + }>; + }; +} + +declare const apiResponse: ApiResponse; + +expectTypeOf(get(apiResponse, 'hits.hits[0]._source.name')).toEqualTypeOf>(); +expectTypeOf(get(apiResponse, 'hits.hits.0._source.name')).toEqualTypeOf>(); + +expectTypeOf(get(apiResponse, 'hits.hits[0]._source.name[0].given[0]')).toBeString(); + +// TypeScript is structurally typed. It's *possible* this value exists even though it's not on the parent interface, so the type is `unknown`. +expectTypeOf(get(apiResponse, 'hits.someNonsense.notTheRightPath')).toBeUnknown(); + +// This interface uses a tuple type (as opposed to an array). +interface WithTuples { + foo: [ + { + bar: number; + }, + { + baz: boolean; + } + ]; +} + +expectTypeOf>().toBeNumber(); +expectTypeOf>().toBeNumber(); + +expectTypeOf>().toBeBoolean(); +expectTypeOf>().toBeUnknown(); + +interface WithNumberKeys { + foo: { + 1: { + bar: number; + }; + }; +} + +expectTypeOf>().toBeNumber(); +expectTypeOf>().toBeNumber(); + +expectTypeOf>().toBeUnknown(); +expectTypeOf>().toBeUnknown(); + +// Test `readonly`, `ReadonlyArray`, optional properties, and unions with null. + +interface WithModifiers { + foo: ReadonlyArray<{ + bar?: { + readonly baz: { + qux: number; + }; + }; + abc: { + def: { + ghi: string; + }; + } | null; + }>; +} + +expectTypeOf>().toEqualTypeOf<{qux: number} | undefined>(); +expectTypeOf>().toEqualTypeOf(); diff --git a/ts41/camel-case.d.ts b/ts41/camel-case.d.ts index 4476fd302..4f9a67bc4 100644 --- a/ts41/camel-case.d.ts +++ b/ts41/camel-case.d.ts @@ -1,13 +1,5 @@ import {WordSeparators} from '../source/utilities'; - -/** -Recursively split a string literal into two parts on the first occurence of the given string, returning an array literal of all the separate parts. -*/ -export type Split = - string extends S ? string[] : - S extends '' ? [] : - S extends `${infer T}${D}${infer U}` ? [T, ...Split] : - [S]; +import {Split} from './utilities'; /** Step by step takes the first item in an array literal, formats it and adds it to a string literal, and then recursively appends the remainder. diff --git a/ts41/get.d.ts b/ts41/get.d.ts new file mode 100644 index 000000000..9103e9aa3 --- /dev/null +++ b/ts41/get.d.ts @@ -0,0 +1,131 @@ +import {Split} from './utilities'; +import {StringDigit} from '../source/utilities'; + +/** +Like the `Get` type but receives an array of strings as a path parameter. +*/ +type GetWithPath = + Keys extends [] + ? BaseType + : Keys extends [infer Head, ...infer Tail] + ? GetWithPath>, Extract> + : never; + +/** +Splits a dot-prop style path into a tuple comprised of the properties in the path. Handles square-bracket notation. + +@example +``` +ToPath<'foo.bar.baz'> +//=> ['foo', 'bar', 'baz'] + +ToPath<'foo[0].bar.baz'> +//=> ['foo', '0', 'bar', 'baz'] +``` +*/ +type ToPath = Split, '.'>; + +/** +Replaces square-bracketed dot notation with dots, for example, `foo[0].bar` -> `foo.0.bar`. +*/ +type FixPathSquareBrackets = + Path extends `${infer Head}[${infer Middle}]${infer Tail}` + ? `${Head}.${Middle}${FixPathSquareBrackets}` + : Path; + +/** +Returns true if `LongString` is made up out of `Substring` repeated 0 or more times. + +@example +``` +ConsistsOnlyOf<'aaa', 'a'> //=> true +ConsistsOnlyOf<'ababab', 'ab'> //=> true +ConsistsOnlyOf<'aBa', 'a'> //=> false +ConsistsOnlyOf<'', 'a'> //=> true +``` +*/ +type ConsistsOnlyOf = + LongString extends '' + ? true + : LongString extends `${Substring}${infer Tail}` + ? ConsistsOnlyOf + : false; + +/** +Convert a type which may have number keys to one with string keys, making it possible to index using strings retrieved from template types. + +@example +``` +type WithNumbers = {foo: string; 0: boolean}; +type WithStrings = WithStringKeys; + +type WithNumbersKeys = keyof WithNumbers; +//=> 'foo' | 0 +type WithStringsKeys = keyof WithStrings; +//=> 'foo' | '0' +``` +*/ +type WithStringKeys> = { + [Key in `${Extract}`]: BaseType[Key] +}; + +/** +Get a property of an object or array. Works when indexing arrays using number-literal-strings, for example, `PropertyOf = number`, and when indexing objects with number keys. + +Note: +- Returns `unknown` if `Key` is not a property of `BaseType`, since TypeScript uses structural typing, and it cannot be guaranteed that extra properties unknown to the type system will exist at runtime. +- Returns `undefined` from nullish values, to match the behaviour of most deep-key libraries like `lodash`, `dot-prop`, etc. +*/ +type PropertyOf = + BaseType extends null | undefined + ? undefined + : Key extends keyof BaseType + ? BaseType[Key] + : BaseType extends { + [n: number]: infer Item; + length: number; // Note: This is needed to avoid being too lax with records types using number keys like `{0: string; 1: boolean}`. + } + ? ( + ConsistsOnlyOf extends true + ? Item + : unknown + ) + : Key extends keyof WithStringKeys + ? WithStringKeys[Key] + : unknown; + +// This works by first splitting the path based on `.` and `[...]` characters into a tuple of string keys. Then it recursively uses the head key to get the next property of the current object, until there are no keys left. Number keys extract the item type from arrays, or are converted to strings to extract types from tuples and dictionaries with number keys. +/** +Get a deeply-nested property from an object using a key path, like Lodash's `.get()` function. + +Use-case: Retrieve a property from deep inside an API response or some other complex object. + +@example +``` +import {Get} from 'type-fest'; +import * as lodash from 'lodash'; + +const get = (object: BaseType, path: Path): Get => + lodash.get(object, path); + +interface ApiResponse { + hits: { + hits: Array<{ + _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}> +``` +*/ +export type Get = GetWithPath>; diff --git a/ts41/utilities.d.ts b/ts41/utilities.d.ts new file mode 100644 index 000000000..f82e362fb --- /dev/null +++ b/ts41/utilities.d.ts @@ -0,0 +1,8 @@ +/** +Recursively split a string literal into two parts on the first occurence of the given string, returning an array literal of all the separate parts. +*/ +export type Split = + string extends S ? string[] : + S extends '' ? [] : + S extends `${infer T}${D}${infer U}` ? [T, ...Split] : + [S];