Skip to content

Extract or modify pieces of arbitrarily nested types with type lenses

License

Notifications You must be signed in to change notification settings

geoffreytools/type-lenses

Repository files navigation

type-lenses

Extract or modify pieces of arbitrarily nested types with type lenses.

How to install

npm install type-lenses

Overview

We are interested in needle in the type bellow

type Haystack = Map<string, {foo: [(f: (arg: string) => needle) => void, 'bar'] }>;

Define a lens

We write a path pointing to our needle:

import { Lens, free, a, r } from 'type-lenses';

type FocusNeedle = Lens<[free.Map, 1, 'foo', 0, a, r]>;

In plain English, the steps are as follows:

  1. Focus on the type Map
  2. Focus on the argument at index 1 in the arguments list
  3. Focus on the field "foo" in the object
  4. Focus on the element at index 0 in the tuple
  5. Focus on the first parameter of the function
  6. Focus on the return type of the function

Extract a piece of type

We can extract our needle with Get:

import { Get } from 'type-lenses';

type Needle = Get<FocusNeedle, Haystack>;

It results in:

needle

Replace a piece of type

We can replace needle by any compatible type with Replace:

import { Replace } from 'type-lenses';

type YihaStack = Replace<FocusNeedle, Haystack, 'Yiha!'>;
// result:
Map<string, {foo: [(f: (arg: string) => 'Yiha!') => void, 'bar'] }>

Map over a type

Similarily to Replace, Over lets us replace needle with the result of applying it to a compatible ready-made or custom free type:

import { Over } from 'type-lenses';

type PromiseStack = Over<FocusNeedle, Haystack, free.Promise>;
// result:
Map<string, {foo: [(f: (arg: string) => Promise<needle>) => void, 'bar'] }>

Find and replace

FindReplace removes the need to construct a path, as long as we know what needle to target:

import { FindReplace } from 'type-lenses';

type YihaStack = FindReplace<Haystack, needle, ['Yiha!']>;
// result:
Map<string, {foo: [(f: (arg: string) => 'Yiha!') => void, 'bar'] }>

It also accepts a replace callback of type Type<[Needle, Path?]> if you need to run arbitrary logic:

// any unary free type can work
type Foo = FindReplace<{ a: 1, b: 2 }, number, free.Promise>;

import { $ReplaceCallback } from 'type-lenses';
import { Optional, Last, A, B } from 'free-types';

// or one of your design (don't freak out, see the doc)
interface $Callback extends $ReplaceCallback<number> {
    type: this['prop'] extends 'a' ? Add<10, A<this>>
        : this['prop'] extends 'b' ? Promise<A<this>>
        : never
    prop: Last<Optional<B, this>>
}

type Bar = FindReplace<{ a: 1, b: 2 }, number, $Callback>;
// result:
type Foo = { a: Promise<1>, b: Promise<2> }
type Bar = { a: 11, b: Promise<2> }

FindReplace gives control over the search, the number of matches and the way they are replaced, with some limitations to keep in mind. Make sure to read the documentation.

Find paths

We can find paths with FindPath and FindPaths.

The former is guaranteed to return a single path pointing to needle, or never:

import { FindPath } from 'type-lenses';

type PathToNeedle = FindPath<Haystack, needle>;
// result:
[free.Map, 1, "foo", 0, Param<0>, Output]
  • Param<0> and Output are aliases for a and r;
  • free.Map is a built-in free type, but you can also register your own so they can be inferred (see doc/$Type);
  • The behaviour for singling out a match is documented in doc/FindPath(s).

The latter returns a tuple of every path leading to needle. If it is self (the default), it returns every possible path, which can be useful for exploring a type.

import { FindPaths } from 'type-lenses';

type EveryPath = FindPaths<Haystack>;
// result:
[[free.Map],
[free.Map, 0],
[free.Map, 1],
[free.Map, 1, "foo"],
[free.Map, 1, "foo", 1],
[free.Map, 1, "foo", 0],
[free.Map, 1, "foo", 0, r],
[free.Map, 1, "foo", 0, a],
[free.Map, 1, "foo", 0, a, a],
[free.Map, 1, "foo", 0, a, r]]

FindPath(s) give control over the search and the number of matches, with some limitations to keep in mind. Make sure to read the documentation.

Audit queries

Finally, we can type check a query, for example in a function:

declare const foo: <
    const Path extends readonly string[] & Check,
    Obj extends object,
    Check = Audit<Path, Obj>
>(path: Path, obj: Obj) => void;

foo(['q', 'b'], { a: { b: 42, c: 2001 }})
//   ~~~ Type "q" is not assignable to type "a"

This behaviour also enables reliable auto-completion:

foo([''], {a: { b: 42, c: 2001 }})
//   -- suggest "a"

foo(['a', ''], {a: { b: 42, c: 2001 }})
//        -- suggest "b" | "c"

Documentation

Type Checking | Lens | Query | Type | Get | GetMulti | Replace | Over | FindReplace | FindPath(s) | Audit | Free utils

Type checking

The library type checks your inputs in various ways, but these checks never involve the Haystack.

This is because the type checker fails to check generics, even with adequate type constraints. Since working with a generic Haystack is a very common use case for lenses, I chose to ignore this check for ease of use.

If you need to check your query, you can do so with a Lens.

Lens

Lens<Query, Model?>

You can create a lens by passing a Query to Lens.

type A = Lens<1>;
type B = Lens<['a', 2, r]>;

Utils such as Get or Replace promote every Query to a Lens, but it is advised to work with lenses when you want to reuse or compose paths.

Composing lenses is as simple as wrapping them in a new Lens:

type C = Lens<[A, B]> // Lens<[1, 'a', 2, r]>

Lens optionally takes a Model against which to perform type checking.

type Haystack = Map<string, {foo: [(f: (arg: string) => needle) => void, 'bar'] }>;
type FocusNeedle = Lens<[free.Map, 1, 'bar', 0, a, r], Haystack>;
//                       ~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Type '[$Map, 1, "bar", 0, a, Output]' does not satisfy the constraint '[$Map, 1, "foo", ...QueryItem[]]'.
//  Type at position 2 in source is not compatible with type at position 2 in target.
//    Type '"bar"' is not assignable to type '"foo"'

Query

A Query is a Lens, a Path or a PathItem

A Path is a tuple of PathItem.

Path items can be one of the following types:

typedescription
Lens As we have already seen, nested lenses are flattened.
Param<⁠index>
or ab, ...f
Focuses on a parameter of a function.
Output or r

Focuses on the return type of a function.

r can be an alias of Output but can also optionally take an argument: r<a> is equivalent to Lens<[a, r]>.

string Focuses on the field of a given name in an object.
number Focuses on the item at a given index in a tuple.
self By default it refers to the haystack, but some utils enable providing a value for it.
$Type Focuses on the arguments list of a type for which $Type is a free type constructor.

$Type

In a nutshell:

// A class we want to reference in a path
class Foo<T extends number> {
    constructor(private value: T) {}
}
npm install free-types
import { Type, A } from 'free-types'

// A free type constructor for that class
interface $Foo extends Type<[A: number]> {
    type: Foo<A<this>>
}

// Imagining a haystack where the needle is the first argument of Foo
type Haystack = Foo<needle>

// Our path would look like this
type FocusNeedle = Lens<[$Foo, 0]>;

type Needle = Get<FocusNeedle, Haystack>; // needle
// We can also define a free utility type
interface $Exclaim extends Type<[string]> { type `${A<this>}!` }

type Exclamation = Over<['a'], { a: 'Hello' }, $Exclaim> // { a: "Hello!" }

type-lenses re-exports a dozen built-in free type constructors under the namespace free.

If you want FindPaths to be able to find your own free types, you must register them:

declare module 'free-types' {
    interface TypesMap { Foo: $Foo }
}

See free-types for more information.

Querying and modifying types

Get

Return the queried piece of type or never if it is not found.

Syntax

Get<Query, Haystack, Self>

parameter description
Query a Query
Haystack The type you want to extract a piece of type from
Self A type which you want self to point to. It is Haystack by default

GetMulti

The same as Get, but takes a tuple of Query and returns a tuple of results.

Syntax

GetMulti<Query[], Haystack, Self>

Replace

Replace the queried piece of type with a new value in the parent type, or return the parent type unchanged if the query is never or doesn't match anything.

Syntax

Replace<Query, Haystack, Value, Constraint?>

parameter description
Query a Query
Haystack The type you want to modify
Value Any type you want to replace the needle with
Constraint A mean of turning off type checking in higher order scenarios

Type checking

Value is type checked against the Query:

type Failing = Replace<[free.WeakSet, 0], WeakSet<{ foo: number }>, number>
//                      ---------------                             ~~~~~~
type Failing = Replace<[free.WeakSet], WeakSet<{ foo: number }>, [number]>
//                      ------------                             ~~~~~~~~
// Type 'number' does not satisfy the constraint 'object'

If Query is generic, you will have to opt-out from type checking by setting Constraint to any:

type Generic<Q> = Replace<Q, WeakSet<{ foo: number }>, object, any>
//                                                             ---

Over

Map over the parent type, replacing the queried piece of type with the result of applying it to the provided free type. Return the parent type unchanged if the query failed.

Syntax

Over<Query, Haystack, $Type, Constraint?>

parameter description
Query a Query
Haystack The type you want to modify
$Type A free type constructor
Constraint A mean of turning off type checking in higher order scenarios

Type checking

The return type of $Type is fully type checked against the Query, however its parameters are only checked loosely for relatedness, because they also depend on the Haystack which is purposely excluded from type checking:

type Failing = Over<[free.WeakSet, 0], WeakSet<{foo: number}>, $Next>
//                                                             ~~~~~
// Type '$Next' does not satisfy the constraint 'Type<[Unrelated<number, object>]>'

type NotFailing = Over<[free.Set, 0], Set<'hello'>, $Next>
//           will blow up with no error   -------   -----

If Query is generic, you will have to opt-out from type checking by setting Constraint to any:

type Generic<Q> = Over<Q, Set<1>, $Next, any>
//                                       ---

FindReplace

Find a Needle in the parent type and replace matches with new values, or return the parent type unchanged if there is no match.

The search behaves like FindPaths.

If there are more matches than you specified replace values, the last replace value is used to replace the supernumerary matches:

type WithValues = FindReplace<[1, 2, 3], number, [42, 2001]>;
// type WithValues = [42, 2001, 2001]

Warning Do not expect object properties to be found and replaced in a specific order. If you need to find/replace multiple values in the same object, use a replace callback instead of a tuple of values.

Syntax

FindReplace<Haystack, Needle, Values | $Type, From?, Limit?>

parameter description
Haystack The type you want to modify
Needle The piece of type you want to search
Values | $Type A tuple of values to replace the matches with, or a replace callback
From A path from which to start the search.
Limit The maximum number of matches to find and replace

Type checking

If you use a replace callback, its first parameter must extend your Needle.

Replace callback

If you want to define a custom replace callback, you can extend $ReplaceCallback<T> which is really Type<[T, Path?]> where T is your Needle:

import { $ReplaceCallback } from 'type-lenses';
import { Optional, Last, A, B } from 'free-types';

interface $Callback extends $ReplaceCallback<number> {
    type: this['prop'] extends 'a' ? Add<10, A<this>>
        : this['prop'] extends 'b' ? Promise<A<this>>
        : never
    prop: Last<Optional<B, this>>
}

type WithCallback = FindReplace<{ a: 1, b: 2 }, number, $Callback>;
// type WithCallback = { a: 11, b: Promise<2> }

The types Optional, A and B let you safely index this to extract the arguments passed to $Callback, while defusing type constraints.

  • Add expects a number, which is satisfied by A<this>;
  • Last expects a tuple, which is satisfied by Optional<B, this>.

More information about these helpers in free-types' guide.

Here I also created a prop field for clarity, using Last to select the last PathItem in the Path.

FindPath(s)

FindPath is literally defined like so:

type FindPath<T, Needle, From extends Path = []> =
    Extract<FindPaths<T, Needle, From, 1>[0], [any, ...any[]]>;

FindPaths returns a tuple of every path leading to the Needle, or every possible path when Needle is self (the default).

The search results are ordered according to the following rules, ranked by precedence:

  1. Matches closer to the root are listed first (breadth-first search);
  2. Matches honour the ordering of tuples and function arguments lists;
  3. Matches do not honour the ordering of object properties;
  4. In function signatures, matched parameters are listed before any matched return type;
  5. the needles any, never and unknown match any, never and unknown respectively (use self to match every path);
  6. When the needle is self, the ordering of paths which do not lead to a BaseType (a leaf) is unspecified.
type BaseType = string | number | boolean | symbol | undefined | void;

Syntax

FindPaths<T, Needle?, From?, Limit?>

parameter description
T The type you want to probe
Needle The piece of type you want selected paths to point to. It defaults to self, which selects every possible path.
From A path from which to start the search.
Limit The maximum number of matches to return

From

From enables you to specify which path should be searched for potential matches. It can be used for disambiguation or to improve performance:

type PathsSubset = FindPaths<{ a: [1], b: [2] }, number, ['b']>
// type PathsSubset = [['b', 0]]

Limit

Limit enables you to ignore matches which are of no interest to you. It also improves performance:

//     provide an empty `From` to access this parameter  vv
type PathsSubset = FindPaths<{ a: [1], b: [2] }, number, [], 1>
// type PathsSubset = [['a', 0]]

Audit

Audit is the type being used internally to type check Lens, but it can be used with functions as well.

It returns a Suggestion which is a type related to a Query that can contain unions and be open-ended:

type Suggestion = Audit<['q', 'b'], { a: { b: number, c: number }}>;
// type Suggestion: ['a', ...QueryItem[]]

type Suggestion = Audit<['a', 'd'], { a: { b: number, c: number }}>;
// type Suggestion: ['a', 'b' | 'c']

Success is represented either by QueryItem, QueryItem[] or readonly QueryItem[] depending on your input. You should not need to check for success, but if you do, consider using the companion type Successful which returns a boolean:

type OK = Successful<Audit<['a', 'b'], { a: { b: number, c: number }}>>;
// type OK: true

Syntax

Audit<Query, Model>

parameter description
Query The Query you want to type check
Model The type that should to be traversable by the Query

Generic Query

Be mindful that type-checking the query will make your function unusable in higher order scenarios.

declare const foo: <
    const Path extends readonly string[] & Check,
    Obj extends object,
    Check = Audit<Path, Obj>
>(path: Path, obj: Obj) => void;

const bar = <
    const Path extends readonly string[],
    Obj extends object
>(path: Path, obj: Obj) => foo(path, obj)
//              cryptic error  ~~~~

An obvious workaround is to check the input in bar and pass the check to foo as a type parameter:

const bar = <
    const Path extends readonly string[] & Check,
    Obj extends object,
    Check = Audit<Path, Obj>
>(path: Path, obj: Obj) =>
    foo<Path, Obj, Check>(path, obj)

Alternatively, you could make type checking optional:

/** pass `any` to `_` in order to disable type-checking*/
declare const foo: <
    const Path extends readonly string[] & Check,
    Obj extends object,
    Check = Audit<Path, Obj>
>(path: Path, obj: Obj, _?: Check) => void;
//                      ---------

const bar = (path: readonly string[], obj: object) =>
    foo(path, { a: { b: null }}, null as any)
//                               -----------

$Get, $GetMulti, $Replace, $Over

Free versions of Get, GetMulti, Replace and Over.

Can be used like so:

import { apply } from 'free-types';

type $NeedleSelector = $Get<Query>
type Needle = apply<$NeedleSelector, [Haystack]>;

About

Extract or modify pieces of arbitrarily nested types with type lenses

Topics

Resources

License

Stars

Watchers

Forks