Skip to content

About

A type-level library enabling the creation and the manipulation of type constructors which can be detached from their type parameters. Also referred to as higher kinded types.

Topics

Resources

Stars

171 stars

Watchers

2 watching

Forks

Repository files navigation

free-types

free-types | Case studies | Documentation | Guide

A type-level library enabling the creation and the manipulation of type constructors which can be detached from their type parameters.

Such "free" type constructors can be passed around and composed, making known programming patterns also available at the type level.

import { Type, apply } from 'free-types'

// From any type constructor
class Foo<T extends number> {
    constructor(private value: T) {}
}

// We can define a type, free from its parameters
interface $Foo extends $Contract {
    type: Foo<this['arg'][0]>
}

// Implementing some contract, carrying their information
type $Contract = Type<[number]>;

// And apply it at a later time, for example in library code
type Foo42 = ApplyWith42<$Foo>;
//    ^? type Foo42 = Foo<42>

// With no knowledge of the concrete implementation
type ApplyWith42<$T extends $Contract> = apply<$T, [42]>;

playground ⮺

The goal of the library is to support dependency inversion and type-level programming. Features include:

  • type constraints on the parameters and on the return type;
  • named, optional and variadic parameters;
  • partial application, and composition with adapters;
  • contravariance in higher order types;
  • type constructor inference;
  • a few experimental features such as pattern matching with destructuring.

Related project: type-lenses

How to use

npm install free-types

Import paths

  • free-types
    core features + compose, adapt and lift free types

  • /core
    create, apply and unwrap free types

  • /strings /arithmetic /logic /tuples
    ready-made free types also under the barrel /utility-types

  • /experimental
    features which API is not stable or performance not optimized

A few use cases

Extending behaviour across packages


  1. Our library provides some type Foo which takes a subtype of number and must return a subtype of number[];
  2. We want our users to be able to extend the behaviour of Foo. To achieve this, we create some kind of interface TN which we export alongside Foo.
module augmentationfree-type
In package foo
//

export type Foo<
    T extends keyof TN<V>,
    V extends number
> = [0, ...TN<V>[T]]

export interface TN<V extends number> {}
In user package
// Implementations ---------------------

import { TN } from 'foo';


declare module 'foo' {
   interface TN<V extends number> {
     T1: [V]

     // Breaks contract without warning
     T2: [V, '2']

     // Deferred warning
     Id: V
   }
}

// Main --------------------------------

import { Foo } from 'foo';


type R1 = Foo<'T1', 1>; // [0, 1]

// Breaks contract without warning
type R2 = Foo<'T2', 1>; // [0, 1, '2']

// Deferred warning
type RI = Foo<'Id', 1>; // [0, ...any[]]
In package foo
import { Type, apply } from 'free-types';

export type Foo<
    $T extends $TN,
    V extends number
> = [0, ...apply<$T, V>]

export type $TN = Type<[number], number[]>
In user package
// ---------------------------------------

import { $TN } from 'foo';
import { A } from 'free-types';

export interface $T1 extends $TN
    { type: [A<this>] }

// Error: $T2 incorrectly extends $TN
export interface $T2 extends $TN
    { type: [A<this>, '2'] } 

// Error: $Id incorrectly extends $TN
export interface $Id extends $TN
    { type: A<this> }


// ---------------------------------------

import { Foo } from 'foo';
import { $T1, $T2, $Id } from './utils';

type R1 = Foo<$T1, 1>; // [0, 1]

// $T2 does not satisfy the constraint $TN
type R2 = Foo<$T2, 1>; // [0, 1, '2']

// $Id does not satisfy the constraint $TN
type RI = Foo<$Id, 1>; // [0, ...any[]]
  1. Foreign packages are not loaded into our workspace, so type checking is deferred until Foo is compiled. tsc --watch also has to be restarted after foo is installed;

  2. The contract on the return type of TN implementations is tacit and cannot be stricter than unknown[] (the minimum required for Foo to compile).

  3. Passing strings as an argument is unclear, and variants could be defined anywhere because TN is global and stateful;

  1. $TN carries all the information required for type checking to take place immediately, both on the call site and on the implementation site;

  2. We were able to express that $TN implementations must return number[], not just unknown[].

  3. Passing types as an argument is self-evident and familiar (higher order functions), and their definition can easily be looked up;

Abstracting over type constructors

We want to write a little util to widen a type, similar to how TS widens values:

const foo = { a: 'foo', b: 2 }; // { a: string, b: number }
type Foo = Widen<{ a: 'foo', b: 2 }> // { a: string, b: number }
vanillafree-type
//

type Widen<T> =
  : T extends string ? string
  : T extends number ? number
  : T extends boolean ? boolean

  : T extends Set<string>
  ? Set<string>
  : T extends Set<number>
  ? Set<number>
  : T extends Set<boolean>
  ? Set<boolean>
  // WeakSet
  // Map
  // WeakMap
  // Promise
  // ...

  : T extends Mappable
  ? { [K in keyof T]: Widen<T[K]> }
  : T

type Mappable = unknown[] | {
    [k: PropertyKey]: unknown
}
import * as $ from 'free-types';

type Widen<T> =
  : T extends string ? string
  : T extends number ? number
  : T extends boolean ? boolean

  : $.unwrap<T> extends {
    type: infer $T extends $.Type,
    args: infer A extends unknown[]
  } ? $.apply<$T, Widen<A>>

  : T extends Mappable
  ? { [K in keyof T]: Widen<T[K]> }
  : T

type Mappable = unknown[] | {
    [k: PropertyKey]: unknown
}
  1. We can't possibly enumerate every combination of built-in types, it would go on and on and on;

  2. Our implementation is not open for extension, there is no way we can deal with user classes.

  1. unwrap relies on TypesMap which already includes built-in type constructors;

  2. TypeMap can be extended with module augmentation. unwrap also optionally accepts a custom map/tuple of free types to narrow down the search.

A more elaborate version of Widen is exported by the library.

Higher level utility types

We have two types, And and Or, which work with 2 arguments and we want to implement the same behaviour for any number or arguments.

ad hoc implementationfree-type
// 

type List = [unknown, ...unknown[]];

type AndN<
    L extends List,
    I extends number = 0,
    R = never
> = I extends L['length'] ? R
    : [L[I]] extends [never] ? never
    : AndN<L, Next<I>, L[I]>;

type OrN<
    L extends List,
    I extends number = 0,
    R = never,
> = I extends L['length'] ? R
    : OrN<L, Next<I>,
        [L[I]] extends [never]
        ? R : L[I]
    >;
import { Reduce } from 'free-types';

type List = [unknown, ...unknown[]];

type AndN<L extends List> =
    Reduce<L, $And>;

type OrN<L extends List> =
    Reduce<L, $Or>;

interface $And extends Type<2> {
    type: And<A<this>, B<this>>
}

interface $Or extends Type<2> {
    type: Or<A<this>, B<this>>
}
Or alternatively
import { $Reduce } from 'free-types';

type $AndN = $Reduce<$And>;
type $OrN = $Reduce<$Or>;

We can get rid of L and List at the cost of having to call $AndN and $OrN with apply.

  1. We did not bother reuse And and Or, because we had to write a lot of code anyway, and in doing so we came up with something smarter;

  2. AndN and OrN would have to be tested, and we know how testing types can be overlooked;

  3. If we had to maintain this, we would have to step through the tail-recursive algorithm.

  1. Reduce saved development time and prevented mistakes by encouraging reuse and reducing the amount of non-trivial code written;

  2. Reusing working code is safer than starting from scratch. Here AndN and OrN can even be proven correct;

  3. The resulting code is very readable, provided we are familiar with the pattern, which is common in JS.

Pattern matching

The listing on the left is an abridged excerpt from actual code where we wanted to switch on a pair of inputs A and B.

We can't pattern match on a tuple if we need control flow analysis to carry over:

type Bar<A extends 1, B extends 1> = ...
type Foo<A, B> = [A, B] extends [1, 1] ?  Bar<A, B> : never
// type 'A' doesn't satisfy constraint '1'   ---

Without free types, we have no other choice than to nest conditionals:

  • because shutting off type checking with an intersection would be error prone, moreover intersections are not always transparent and can produce unwanted results;
  • because an implementation of Match taking plain utility types would eagerly evaluate them, regardless of which clause (if any) is a match.
vanillafree-type
type Foo<A, B, V> =
  A extends O
    ? B extends F
      ? R1<B, V>
      : never
  : A extends P
    ? B extends F
      ? R2<V, A, B>
      : never
  : A extends K
    ? B extends U
      ? R3<B, A, V>
        : B extends N
        ? R4<V, A, B> 
        : never
  : A extends S
    ? B extends W
      ? V
      : never
  : A extends T
    ? B extends W
      ? R5<A, V>
      : never
  : never
import { Match } from 'free-types/experimental';

type Foo<A, B, V> =
    Match<[A, B], [
        [ [O, F], $R1<V>, [1] ],
        [ [P, F], $R2<V> ],
        [ [K, U], $R3<V> ],
        [ [K, N], $R4<V> ],
        [ [S, W], V ],
        [ [T, W], $R5<V>, [0] ]
    ]>
  1. Clauses depend on each-other, making it is easy to miss a case or misinterpret what falls through to the last branch;

  2. A good part of the code is plumbing and indentation hell.

  1. Every case is scoped (notably the ones in which A extends K). The cognitive load is lower;

  2. The wiring disappears to let your domain show through.

Composition

We have 2 types F<A, B> and G<T>. We want to create a type H such that H<A, B> = G<F<A, B>> (provided the types line up).

vanillafree-type
import { F, G, CA, CB } from './FG.ts';

type H<A extends CA, B extends CB> =
    G<F<A, B>>;
import { $F, $G } from './FG.ts';

type $H = Flow<[$F, $G]>;
  1. The cost of repeating or even bringing into scope type constraints makes the pattern unappealing;

  2. H needs to know a great deal about F constraints.

  1. The ideal of using better named / simpler types becomes more reachable;

  2. Combinators such as Flow take care of the wiring of type constraints and hide unnecessary details.

Other composition patterns

A single free type can have a fertile and type-safe life.

Take the procedural type $Stitch:

import { apply } from 'free-types';
import { $Stitch } from 'free-types/strings';

type FooBar = apply<$Stitch, ['-', 'foo', 'bar']> // "foo-bar"

We can partially apply it to create special-purpose types:

// With this familiar syntax, if you decide to implement it
type $Concat = $Stitch<''>;

type Concatenated = apply<$Concat, ['foo', 'bar']>;
// "foobar"
// `partial` or `partialRight` are always an option
import { partial } from 'free-types';

type $Amazed = partial<$Concat, ['Waw, ']>;

type Reaction = apply<$Amazed, ['this UI is gorgeous']>;
// "Waw, this UI is gorgeous"

We can compose more complex types:

import { Flow, partialRight } from 'free-types';

type $OverReact = Flow<[$Amazed, partialRight<$Concat, ['!!!!']>]>;

type OverReaction = apply<$OverReact, ['look at this website']>;
// "Waw, look at this website!!!!"

Arguments can be modified before they are applied:

import { $Before, Const } from 'free-types';

type $Drunk = $Before<$Amazed, Const<'*hips*'>>;

type Failure = apply<$Drunk, ['The space in the Schwarzschild metric does not expand']>;
// "Waw, *hips*"

We can map over elements of a list or object:

import { MapOver } from 'free-types';

type Facts = ['the Moon is a satellite', 'France is not in Canada'];
type RealityShowQuotes = MapOver<Facts, $Amazed>;
// [
//  "Waw, the Moon is a satellite"
//  "Waw, France is not in Canada"
//]

Arguments can be routed from lists or objects:

import { Lift } from 'free-types';

type FirstNames = { a: 'Alan', b: 'Grace' };
type LastNames = { a: 'Turing', b: 'Hopper' };

type Names = Lift<$Stitch<' '>, [FirstNames, LastNames]>;
// { a: "Alan Turing", b: "Grace Hopper" }

One can't mention map or lift without reduce:

import { Reduce } from 'free-types';

type $Hesitation = $Stitch<', you know, '>;
type Sentence = ['Well', 'it may just be a habit', 'or some other cause'];

type HesitantSpeech = Reduce<Sentence, $Hesitation>
// "Well, you know, it may just be a habit, you know, or some other cause"

There is nothing stopping a higher order type from being a free-type itself:

import { $Reduce } from 'free-types';

type $BarackObama = $Reduce<$Stitch<' . . . '>>;

type ObamaSpeech = apply<$BarackObama, [Sentence]>;
// "Well . . . it may just be a habit . . . or some other cause"

Quirks and limitations

  • A free type is evaluated a first time when it is defined, without arguments.
    The guide explains how to define a good fallback for its return type.

  • A type constraint on a free type's parameter can't depend on another one,
    nor run procedural checks on its argument, because it does not exist yet.

  • writing more abstract code in general will make you hit TS own quirks and gaps more often.
    Many free-types utils give you an escape hatch so your code still compiles

  • Some free-types conveniences can impact performance.
    The guide explains how to keeping it under control.

About

A type-level library enabling the creation and the manipulation of type constructors which can be detached from their type parameters. Also referred to as higher kinded types.

Topics

Resources

Stars

171 stars

Watchers

2 watching

Forks

Used by

Contributors

Languages