2.1.1 • Published 3 years ago

@miscellany/types v2.1.1

Weekly downloads
-
License
MIT
Repository
-
Last release
3 years ago

This library provides utilities for working with types in TypeScript.

Modules

  • /hkt: Provides higher-kinded types as well as runtime representatives for those types.

Module /hkt

This module provides a simulation of higher-kinded types in TypeScript.

Consuming:

import { TypeConstructor, Apply } from '@miscellany/types/hkt';
// Or use shorter aliases
import { TCtor, Ap } from '@miscellany/types/hkt';
// Or import Hkt module from package root
import { Hkt } from '@miscellany/types';

// Basic example
// Note: Return type is optional and defaults to 'any'
interface MyTCtor extends TCtor <[string], string> {
  result: `Some prefixed text - ${this['params'][0]}`;
}
type Applied = Ap <MyTCtor, ['Hello, world!']>;
// => Applied: 'Some prefixed text - Hello, world!'

Implementation

@miscellany/types/hkt utilizes defunctionalization of types to express type functions. The basic concept here is to express a type which depends on other types entirely as a structure without generic parameters.

The specific language features used to simulate higher-kinded types are: 1. this polymorphism in interfaces 2. Intersection collapse of unknown & T to T

A minimal implementation of higher-kinded types stripped of the extra features this library provides might look something like the following:

// All type functions extend from this interface
interface TypeConstructor {
  params: unknown;
  result: unknown;
}

type Apply <Fn extends TypeConstructor, Params> =
  (Fn & { params: Params })['result'];

// == Usage ==

// Specific instance of a type function
interface PairOf extends TypeConstructor {
  result: [this['params'], this['params']];
}

type PairOfNumber = Apply <PairOf, number>;
// => [number, number]

Defining an interface which extends from TypeConstructor creates a context in which this may be used to access the params property. When Apply is evaluated later with a type parameter number, the compiler will see the following union { params: unknown, result: [this['params'], this['params']] } & { params: number }.

The subsequent step of indexing result on the union type forces the compiler to evaluate the expression, unifying the properties on the type. An intermediate representation of this would be { params: unknown & number, result: [this['params'], this['params']] }, which collapses to { params: number, result: [this['params'], this['params']] }, which returns [number, number] when result is indexed.

This library builds further upon this idea, providing the ability to constrain the parameter and return types of type constructors, as well as the ability to produce new type constructors through partial application and composition. In this repository, a standard TypeConstructor specifically requires the params property to a tuple to allow for multiple parameters to the TypeConstructor.

Examples

Constrained parameters

The type parameters of a type function may be constrained as follows:

// This type function takes a single parameter which must extend 'number'
interface PairOfNumbers extends TypeConstructor <[number]> {
  result: [this['params'][0], this['params'][0]];
}

type PairOfZeros = Apply <PairOfNumbers, [0]>;
// => [0, 0]

type PairOfHelloWorld = Apply <PairOfNumbers, ['hello world']>;
// Type '["hello world"]' does not satisfy the constraint '[number?]'.
//   Types of property '0' are incompatible.
//     Type 'string' is not assignable to type 'number'.



// Constrained parameter types are especially useful when dealing with nested properties
interface MyArgType {
  x: {
    y: {
      z: {
        deeplyNested: number
      }
    }
  }
}

// Since the parameters are automatically constrained to be of type MyArgType, there is no
//  need to use a conditional type to restrict this['params']
interface UseDeeplyNestedUnnecessaryConditional extends TypeConstructor <[MyArgType]> {
  result: this['params'] extends MyArgType ? this['params']['x']['y']['z']['deeplyNested'] : never;
}

// Instead, just use the properties available on the parameters. They are already of the correct type.
interface UseDeeplyNested extends TypeConstructor <[MyArgType]> {
  result: this['params']['x']['y']['z']['deeplyNested'];
}

type DeeplyNestedResult = Apply <UseDeeplyNested, [{ x: { y: { z: { deeplyNested: number } } } }]>;
// => number

Constrained return type

The return type of a type function may be constrained as well. This is useful when defining type functions that can compose together:

// When a TypeConstructor returns the wrong type, a TypeError is returned
// Interface 'ReturnTypeWrong' incorrectly extends interface 'TypeConstructor<[string], number>'.
//   Types of property 'result' are incompatible.
//     Type 'this["params"][0]' is not assignable to type 'number'.
//       Type 'string' is not assignable to type 'number'.
interface ReturnTypeWrong extends TypeConstructor <[string], number> {
  result: this['params'][0];
}

// TupleLength takes a string and returns a number
interface TupleLength extends TypeConstructor <[any[]], number> {
  result: this['params'][0]['length'];
}

interface CountingNumberGreaterThan5 extends TypeConstructor <[number]> {
  result: this['params'][0] extends 1 | 2 | 3 | 4 | 5 ? false : true;
}

type TupleLengthGt5 = Compose <[TupleLength, CountingNumberGreaterThan5]>;

type HasMoreThan5Elements1 = Apply <TupleLengthGt5, [[any, any, any, any, any, any]]>;
// => true
type HasMoreThan5Elements2 = Apply <TupleLengthGt5, [[any, any]]>;
// => false

Composition

Type constructors can be composed into new type constructors using the Compose and Pipe utilities. Type constructors produced in this manner behave in the same way as ordinary type constructors; the parameter constraint will come from the first provided type constructor, and the return type will come from the last provided type constructor.

interface AppendNumber extends TypeConstructor <[], [number]> {
  result: [number];
}

interface AppendString extends TypeConstructor <[[any]], [any, string]> {
  result: [this['params'][0][0], string];
}

interface AppendBool extends TypeConstructor <[[any, any]], [any, any, boolean]> {
  result: [this['params'][0][0], this['params'][0][1], boolean];
}

interface AppendConst extends TypeConstructor <[[any, any, any]], [any, any, any, string]> {
  result: [this['params'][0][0], this['params'][0][1], this['params'][0][2], 'Hello world!'];
}

type Make4TuplePiped = Pipe <[AppendNumber, AppendString, AppendBool, AppendConst]>;
type ResultPiped = Apply <Make4TuplePiped, []>;
// => [number, string, boolean, "Hello world!"]

// The only difference in the Pipe and Compose utilities is the order of the parameters.
// Pipe flows parameters through type constructors from the start of the list to the end,
//  while Compose creates a type constructor by composing the members of the list, meaning
//  it parameters are applied in reverse order to Pipe.
type Make4TupleComposed = Compose <[AppendConst, AppendBool, AppendString, AppendNumber]>;
type ResultComposed = Apply <Make4TupleComposed, []>;
// => [number, string, boolean, "Hello world!"]

The return types and parameter types of each composed function must line up with the next one in line, or the return type will be never. Unfortunately, there is no known mechanism for producing an actionable type error when a mismatch occurs, so it is up to the library consumer to inspect their types for incorrect composition.

interface ReturnsString extends TypeConstructor <[any], string> {
  return: 'hello world';
}

interface AcceptsNumber extends TypeConstructor <[number], [number]> {
  result: [this['params'][0]];
}

// No error will be provided here, even though the required parameter type of AcceptsNumber
//  is not satisfied.
type IncorrectComposition = Compose <[ReturnsString, AcceptsNumber]>;

// And this will always result in 'never'
type Result = Apply <IncorrectComposition, [123]>;
// => never

Partial application

All TypeConstructors from @miscellany/types are automatically curried.

When applying a type constructor with less parameters than it requires, a new type constructor will be returned, which can itself be provided to Apply and Compose.

interface Concat extends TypeConstructor <[string, string], string> {
  result: `${this['params'][0]}${this['params'][1]}`;
}

type ConcatHello = Apply <Concat, ['Hello']>;
type GreetEarth = Apply <ConcatHello, [' world']>;
// => "Hello world"

This ability to partially apply a type constructor inline with the rest of its definition opens the door to some interesting (albeit discouraged by the TS team) type level implementations of list operations.

// Utilities
type Tail <T extends Array <any> | ReadonlyArray <any>> =
((...t: T) => any) extends ((head: any, ...tail: infer TTail) => any)
  ? TTail
  : never;

type Head <T extends Array <any> | ReadonlyArray <any>> =
((...t: T) => any) extends ((head: infer THead, ...tail: any[]) => any)
  ? THead
  : never;

// == The main event! ==
type _MapList <List extends any[], Fn extends TypeConstructor <[any]>, Acc extends any[] = []> = {
  0: Acc;
  1: _MapList <Tail <List>, Fn, [...Acc, Apply <Fn, [Head <List>]>]>;
}[List['length'] extends 0 ? 0 : 1];

interface MapList extends TypeConstructor <[TypeConstructor <[any]>, any[]]> {
  result: _MapList <this['params'][1], this['params'][0]>;
}

// Type to use as Fn when mapping. Flips the 'Apply' type and wraps it in a type constructor 
interface ApplyTo extends TypeConstructor <[any, TypeConstructor <[any]>]> {
  result: Apply <this['params'][1], [this['params'][0]]>;
}

// Types to call in map
interface PassThrough extends TypeConstructor <[any]> { result: this['params'][0] }

type KV <K extends string, V> = { [k in K]: V };
interface PropX extends TypeConstructor <[any]> { result: KV <'x', this['params'][0]> }
interface PropY extends TypeConstructor <[any]> { result: KV <'y', this['params'][0]> }

type TypeConstructorList = [PassThrough, PropX, PropY];
type ApplyAllToNumber = Apply <MapList, [Apply <ApplyTo, [number]>]>;
type AllAppliedToNumber = Apply <ApplyAllToNumber, [TypeConstructorList]>;
// => [number, KV<"x", number>, KV<"y", number>]
type Second = AllAppliedToNumber[1];
// => { x: number }

Some partial application scenarios may be constructed using a mix of TypeConstructors and built-in generics.

// Curry in a type parameter using basic generics
interface Append <T> extends TypeConstructor <[any[]], [...any, T]> {
  result: [...this['params'][0], T];
}

type T = Apply <Append <number>, [[string, boolean]]>;
// => [string, boolean, number]

The same effect may be accomplished using partial application.

interface Append extends TypeConstructor <[any, any[]], any[]> {
  result: [...this['params'][1], this['params'][0]];
}

type AppendNumber = Apply <Append, [number]>;
type ResultFromPartialParams = Apply <AppendNumber, [[string, boolean]]>;
// => [string, boolean, number]
type ResultFromCompleteParams = Apply <Append, [number, [string, boolean]]>;
// => [string, boolean, number]

Custom partial application

The standard TypeConstructor interface provides list based partial application of parameters, and for the most part, this is sufficient. However, if your use case requires custom partial application, you can create a custom type constructor by extending TypeConstructorBase.

The first step in this process is to define the expected shape of your parameters, and to provide type constructors to handle the steps of partial application: merging two sets of parameters, taking the difference between expected parameters and given parameters, checking that the parameter requirement is satisfied, and creating a partial representation to allow partially provided parameters.

interface CustomParamsType <X, Y> {
  x: X;
  y: Y;
}

// Combine two CustomParamsType
interface MergeParams extends TypeConstructorBase <[Partial <CustomParamsType <any, any>>, Partial <CustomParamsType <any, any>>]> {
  result: this['params'][0] & this['params'][1];
}

// Get the remaining required properties
type DiffCustomParams <
  Given extends Partial <CustomParamsType <any, any>>,
  Constraint extends Partial <CustomParamsType <any, any>>
> = (
  { [K in Exclude <keyof Constraint, keyof Given>]: Constraint[K] }
);
interface DiffParams extends TypeConstructorBase <[Partial <CustomParamsType <any, any>>, Partial <CustomParamsType <any, any>>]> {
  result: DiffCustomParams <this['params'][0], this['params'][1]>;
}

// Check that all required properties are present. Keep in mind that the constraint at this point may not be a complete CustomParamsType.
// If one or more partial applications have ocurred at this point, the current constraint may be a subset of the initial constraint.
interface AllPropsPresent extends TypeConstructorBase <[Partial <CustomParamsType <any, any>>, Partial <CustomParamsType <any, any>>]> {
  //      keyof Given params      extends keyof Param constraint
  result: keyof this['params'][1] extends keyof this['params'][0] ? true : false;
}

// Create a partial representation that allows specifying a subset of the required properties.
interface PartialParams extends TypeConstructorBase <[Partial <CustomParamsType <any, any>>]> {
  result: Partial <this['params'][0]>;
}

Now that the steps of partial application have been described for our custom type constructor, it is time to define it. The following creates a type constructor that requires its constraint to be of our CustomParamsType and which specifies each step for partial application with the type constructors defined above.

interface CustomTypeConstructor <
  ParamConstraint extends CustomParamsType <any, any> = CustomParamsType <any, any>,
  ReturnConstraint = unknown
> extends TypeConstructorBase <ParamConstraint, ReturnConstraint> {
  mergeParams: MergeParams;
  diffParams: DiffParams;
  paramsSatisfied: AllPropsPresent;
  partialParams: PartialParams;
}

Once the custom type constructor has been defined, it will behave as any other type constructor, providing compiler errors for incorrect parameter types as well as supporting partial application.

// An instance of our custom type constructor that accepts an { x: number, y: string } and returns a string.
interface UsesCustomTypeConstructor extends CustomTypeConstructor <{ x: number; y: string }, string> {
  result: `${this['params']['x']} ${this['params']['y']}`;
}

// Full application immediately returns the result
type FullyApplied = Ap <UsesCustomTypeConstructor, { x: 123; y: 'Hello world!' }>;
// => "123 Hello world!"

// Partial application returns another type constructor that is waiting for the rest of its parameters.
type PartiallyApplied = Ap <UsesCustomTypeConstructor, { x: 123 }>;
type IncorrectlyApplied = Ap <PartiallyApplied, { y: 123 }>;
// Type '{ y: 123; }' does not satisfy the constraint 'Partial<DiffCustomParams<{ x: 123; }, { x: number; y: string; }>>'.
//   Types of property 'y' are incompatible.
//     Type 'number' is not assignable to type 'string'.
type EventuallyApplied = Ap <PartiallyApplied, { y: 'Hello world!' }>;
// => "123 Hello world!"

Type representatives

In addition to providing virtual type functions in the form of TypeConstructor, @miscellany/types/hkt also provides a runtime construct for representing your types with concrete constructors.

Type representatives take two sets of parameters. The first are virtual, are provided through generics application, and configure the type constructor that the result will represent. The second set of parameters provide a constructor function for building a concrete instance of the type, an optional type guard function to be attached to the representative, and an optional dictionary of static values to be attached to the representative.

The resulting representative is a curried function which takes virtual parameters first and concrete parameters last. This is the only restriction on which type constructors may be accurately represented with a type representative; type constructors must take all virtual type parameters first and concrete type parameters second.

When virtual parameters are required, the type representative must be called at least twice to produce an instance. This is because TypeScript does not allow partial parameter inference as of the publication of this library, and as such, one call must be used to explicitly apply virtual type parameters, and a separate call must be used to infer types from concrete parameters.

Basic Example

The following example is a minimal use of the TypeRepresentative. Note that the implementation of TupleOf is omitted for brevity, but it's output is demonstrated in a comment.

// For brevity, TupleOf is not provided
// Example output: TupleOf <any, 5> => [any, any, any, any, any]

// Add two integer at type level!
// Obviously this will blow up if you plug in negatives or floats.
interface Example1 extends TCtor <[number, number]> {
  result: [
    ...TupleOf <any, this['params']['0']>,
    ...TupleOf <any, this['params']['1']>
  ]['length'];
}

// Example application of Example1 type constructor
type Added = Ap <Example1, [4, 7]>;
// => Added = 11

// Create a type representative for the type constructor
const Example1TRep =
  TypeRepresentative
  // One function call for providing virtual type parameters
  <Example1> ()
  // One function call for providing runtime values.
  ((n1, n2) => n1 + n2, 2);

// Calling the resulting type representative produces an instance of the wrapped
// type constructor both at the type level and at runtime.
const added = Example1TRep (1, 3);
// => added: 4 = 4

// The runtime representative is auto-curried just like the type-level constructor.
const add4  = Example1TRep (4);
const seven = add4 (3);
// => seven: 7 = 7

Full Example

The basic example provides just the bare minimum to use TypeRepresentative. The following example uses everything offered:

  • Virtual parameter application in a concrete context with empty curried calls
  • Static properties on the type representative
  • Type guard function with is
// Instance type
type VirtualProperties <A, B> = { p1?: A; p2?: B };
interface Example2 extends TCtor <[any, any, number, string, boolean]> {
  result:
  & VirtualProperties <this['params']['0'], this['params']['1']>
  & [this['params']['2'], this['params']['3'], this['params']['4']];
}

// Statics type
interface GetP1 {
  getP1: <P extends Ap <Example2, Example2['paramConstraint']>> (p: P) => P['p1'];
}
// Type constructor which takes another type constructor. This decouples the statics from
// the instance while still allowing the statics to do operations involving the instance.
// For this example, we already had the instance type directly available, so we use that
// instead
interface Example2Statics extends TCtor <[TCtor]> {
  result: GetP1;
}

// Concrete type representative
const Example2TRep =
  TypeRepresentative
  // Wrapped type constructor configuration provided using virtual type parameter application
  <Example2, { Statics: Example2Statics; VirtualParams: 2 }> ()
  (
    // Concrete constructor function for producing an instance that conforms to the result of the
    // type constructor.
    (...p) => p,
    // Must always specify the arity of the constructor function for runtime currying
    3,
    // Concrete statics. Note that 'inst' is already of type Ap <MyTCtor, MyTCtor['paramConstraint']>
    { getP1: (inst) => inst.p1 },
    // Optional type guard.
    (item): item is Ap <Example2, Example2['paramConstraint']> => (
      Array.isArray (item)
      && item.length === 3
      && typeof item[0] === 'number'
      && typeof item[1] === 'string'
      && typeof item[2] === 'boolean'
    ),
  );

// Basic runtime instance construction
const instance =
  Example2TRep
  // Virtual type parameter application. At runtime, this is an empty curried call that does nothing.
  <['bar', 'foo']> ()
  // Concrete parameter application and inference of remaining type parameters for represented type constructor
  // This will return a concrete instance using the function provided to the type representative.
  (123, '123', false);

// Demonstration of types of instance properties
const [a, b, c] = instance;
// => a: 123
// => b: '123'
// => c: false

const d = instance.p1;
// => d: 'bar'
// Virtual for type storage; marked as optional and is never defined

const e = instance.p2;
// => e: 'foo'
// Virtual for type storage; marked as optional and is never defined

// Using partial application for both concrete and virtual type parameters.
const p1 = Example2TRep <['foo']> ();
const p2 = p1 <['bar']> ();
const p3 = p2 (123);
const p4 = p3 ('123', false);

const p5 = p3 ('123', 'false');
// Argument of type 'string' is not assignable to parameter of type 'boolean'.

// Using statics
const propFromStatic = Example2TRep.getP1 (instance);
// => propFromStatic: "bar"

// Narrowing a value to the instance type using the 'is' static property
const narrowWithTypeGuard = (item: any) => {
  if (Example2TRep.is (item))
    return item[0];
    // => item: VirtualProperties<any, any> & [number, string, boolean]

  return null;
};
// => narrowWithTypeGuard: (item: any) => number
2.1.1

3 years ago

2.1.0

3 years ago

2.0.0

3 years ago

1.1.1

3 years ago

1.1.0

3 years ago

1.0.4

3 years ago

1.0.3

3 years ago

1.0.2

3 years ago

1.0.1

3 years ago

1.0.0

3 years ago