1.1.0 • Published 2 years ago

@stephanealnet-signalwire/ts-algebra v1.1.0

Weekly downloads
-
License
MIT
Repository
github
Last release
2 years ago

Types on steroids 💊

ts-algebra exposes a subset of TS types called Meta-types: Meta-types are types that encapsulate other types.

import { Meta } from "ts-algebra";

type MetaString = Meta.Primitive<string>;

The encapsulated type can be retrieved using the Resolve operation.

type Resolved = Meta.Resolve<MetaString>;
// => string 🙌

You can also use the more compact M notation:

import { M } from "ts-algebra";

type Resolved = M.Resolve<
  M.Primitive<string>
>;

Okay, but... why ? 🤔

Meta-types allow operations that are not possible with conventional types.

For instance, they allow new "intersect" and "exclude" operations, and handling objects additional properties:

type MyObject = {
  str: string; // <= ❌ "str" is assignable to string
  [key: string]: number;
};

type MyObjectKeys = keyof MyObject;
// => string <= ❌ Unable to isolate "str"

Think of meta-types as a parallel universe where all kinds of magic can happen 🌈 Once your computations are over, you can retrieve the results by resolving them.

Meta-types were originally part of json-schema-to-ts. Check it to see a real-life usage.

Table of content

☁️ Installation

# npm
npm install --save-dev ts-algebra

# yarn
yarn add --dev ts-algebra

🧮 Cardinality

A bit of theory first:

  • The cardinality of a type is the number of distinct values (potentially infinite) that can be assigned to it
  • A meta-type is said representable if at least one value can be assigned to its resolved type (cardinality ≥ 1)

An important notion to keep in mind using ts-algebra:



Any other non-representable meta-type (e.g. an object with a non-representable but required property) will be instanciated as M.Never.

There are drawbacks to this choice (the said property is hard to find and debug) but stronger benefits: This drastically reduces type computations, in particular in intersections and exclusions. This is crucial for performances and stability.

✨ Meta-types

Any

import { M } from "ts-algebra";

type Resolved = M.Resolve<
  M.Any
>;
// => unknown

Never

import { M } from "ts-algebra";

type Resolved = M.Resolve<
  M.Never
>;
// => never

Const

Used for types with cardinalities of 1.

Arguments:

  • Value (type)
import { M } from "ts-algebra";

type Resolved = M.Resolve<
  M.Const<"I love pizza">
>;
// => "I love pizza"

Enum

Used for types with finite cardinalities.

Arguments:

  • Values (type union)
import { M } from "ts-algebra";

type Food = M.Resolve<
  M.Enum<"pizza" | "tacos" | "fries">
>;
// => "pizza" | "tacos" | "fries"

☝️ M.Enum<never> is non-representable

Primitive

Used for either string, number, boolean or null.

Arguments:

  • Value (string | number | boolean | null)
import { M } from "ts-algebra";

type Resolved = M.Resolve<
  M.Primitive<string>
>;
// => string

Array

Used for lists of items of the same type.

Arguments:

  • Items (?meta-type = M.Any)
import { M } from "ts-algebra";

type Resolved = M.Resolve<
  M.Array
>;
// => unknown[]

type Resolved = M.Resolve<
  M.Array<M.Primitive<string>>
>;
// => string[]

☝️ Any meta-array is representable by []

Tuple

Used for finite, ordered lists of items of different types.

Meta-tuples can have additional items, typed as M.Never by default. Thus, any meta-tuple is considered closed (additional items not allowed), unless a representable additional items meta-type is specified, in which case it becomes open.

Arguments:

  • RequiredItems (meta-type[]):
  • AdditionalItems (?meta-type = M.Never): Type of additional items
import { M } from "ts-algebra";

type Resolved = M.Resolve<
  M.Tuple<[M.Primitive<string>]>
>;
// => [string]

type Resolved = M.Resolve<
  M.Tuple<
    [M.Primitive<string>],
    M.Primitive<string>
  >
>;
// => [string, ...string[]]

☝️ A meta-tuple is non-representable if one of its required items is non-representable

Object

Used for sets of key-value pairs (properties) which can be required or not.

Meta-objects can have additional properties, typed as M.Never by default. Thus, any meta-object is considered closed (additional properties not allowed), unless a representable additional properties meta-type is specified, in which case it becomes open.

In presence of named properties, open meta-objects additional properties are resolved as unknown to avoid conflicts. However, they are used as long as the meta-type is not resolved (especially in intersections and exclusions).

Arguments:

  • NamedProperties (?{ key:string: meta-type } = {})
  • RequiredPropertiesKeys (?string union = never)
  • AdditionalProperties (?meta-type = M.Never): The type of additional properties
import { M } from "ts-algebra";

type Resolved = M.Resolve<
  M.Object<
    {
      required: M.Primitive<string>;
      notRequired: M.Primitive<null>;
    },
    "required",
    M.Primitive<number>
  >
>;
// => {
//  req: string,
//  notRequired?: null,
//  [key: string]: unknown
// }

☝️ A meta-object is non-representable if one of its required properties value is non-representable:

  • If it is a non-representable named property
  • If it is an additional property, and the object is closed

Union

Used to combine meta-types in a union of meta-types.

Arguments:

  • Values (meta-type union)
import { M } from "ts-algebra";

type Food = M.Resolve<
  M.Union<
    | M.Primitive<number>
    | M.Enum<"pizza" | "tacos" | "fries">
    | M.Const<true>
  >
>;
// => number
// | "pizza" | "tacos" | "fries"
// | true

☝️ A meta-union is non-representable if it is empty, or if none of its elements is representable

🔧 Methods

Resolve

Resolves the meta-type to its encapsulated type.

Arguments:

  • MetaType (meta-type)
import { M } from "ts-algebra";

type Resolved = M.Resolve<
  M.Primitive<string>
>;
// => string

Intersect

Takes two meta-types as arguments, and returns their intersection as a meta-type.

Arguments:

  • LeftMetaType (meta-type)
  • RightMetaType (meta-type)
import { M } from "ts-algebra";

type Intersected = M.Intersect<
  M.Primitive<string>,
  M.Enum<"I love pizza"
    | ["tacos"]
    | { and: "fries" }
  >
>
// => M.Enum<"I love pizza">

Meta-type intersections differ from conventional intersections:

type ConventionalIntersection =
  { str: string } & { num: number };
// => { str: string, num: number }

type MetaIntersection = M.Intersect<
  M.Object<
    { str: M.Primitive<string> },
    "str"
  >,
  M.Object<
    { num: M.Primitive<number> },
    "num"
  >
>;
// => M.Never: "num" is required in B
// ...but denied in A

Intersections are recursively propagated among tuple items and object properties, and take into account additional items and properties:

type Intersected = M.Intersect<
  M.Tuple<
    [M.Primitive<number>],
    M.Primitive<string>
  >,
  M.Tuple<
    [M.Enum<"pizza" | 42>],
    M.Enum<"fries" | true>
  >
>;
// => M.Tuple<
//  [M.Enum<42>],
//  M.Enum<"fries">
// >

type Intersected = M.Intersect<
  M.Object<
    { food: M.Primitive<string> },
    "food",
    M.Any
  >,
  M.Object<
    { age: M.Primitive<number> },
    "age",
    M.Enum<"pizza" | "fries" | 42>
  >
>;
// => M.Object<
//  {
//    food: M.Enum<"pizza" | "fries">,
//    age: M.Primitive<number>
//  },
//  "food" | "age",
//  M.Enum<"pizza" | "fries" | 42>
// >

Intersections are distributed among unions:

type Intersected = M.Intersect<
  M.Primitive<string>,
  M.Union<
    | M.Const<"pizza">
    | M.Const<42>
  >
>;
// => M.Union<
//  | M.Const<"pizza">
//  | M.Never
// >

Exclude

Takes two meta-types as arguments, and returns their exclusion as a meta-type.

Arguments:

  • SourceMetaType (meta-type)
  • ExcludedMetaType (meta-type)
import { M } from "ts-algebra";

type Excluded = M.Exclude<
  M.Enum<"I love pizza"
    | ["tacos"]
    | { and: "fries" }
  >,
  M.Primitive<string>,
>
// => M.Enum<
//  | ["tacos"]
//  | { and: "fries" }
// >

Meta-type exclusions differ from conventional exclusions:

type ConventionalExclusion = Exclude<
  { req: string; notReq?: string },
  { req: string }
>;
// => never
// ObjectA is assignable to ObjectB

type MetaExclusion = M.Exclude<
  M.Object<
    {
      req: M.Primitive<string>;
      notReq: M.Primitive<string>;
    },
    "req"
  >,
  M.Object<
    { req: M.Primitive<string> },
    "req"
  >
>;
// => ObjectA
// Exclusion is still representable
type ConventionalExclusion = Exclude<
  { food: "pizza" | 42 },
  { [k: string]: number }
>;
// => { food: "pizza" | 42 }

type MetaExclusion = M.Exclude<
  M.Object<
    { food: M.Enum<"pizza" | 42> },
    "food"
  >,
  M.Object<
    {},
    never,
    M.Primitive<number>
  >
>;
// => M.Object<
//  { food: M.Enum<"pizza"> },
//  "food"
// >

When exclusions can be collapsed on a single item or property, they are recursively propagated among tuple items and object properties, taking into account additional items and properties:

type Excluded = M.Exclude<
  M.Tuple<[M.Enum<"pizza" | 42>]>,
  M.Tuple<[M.Primitive<number>]>
>;
// => M.Tuple<[M.Enum<"pizza">]>

type Excluded = M.Exclude<
  M.Tuple<
    [M.Enum<"pizza" | 42>],
    M.Enum<"fries" | true>
  >,
  M.Tuple<
    [M.Primitive<number>],
    M.Primitive<string>
  >
>;
// => TupleA
// Exclusion is not collapsable on a single item

type Excluded = M.Exclude<
  M.Object<
    {
      reqA: M.Enum<"pizza" | 42>;
      reqB: M.Enum<"pizza" | 42>;
    },
    "reqA" | "reqB"
  >,
  M.Object<
    {},
    never,
    M.Primitive<number>
  >
>;
// => ObjectA
// Exclusion is not collapsable on a single property

Exclusions are distributed among unions:

type Excluded = M.Exclude<
  M.Union<
    | M.Const<"pizza">
    | M.Const<42>
  >,
  M.Primitive<number>
>;
// => M.Union<
//  | M.Const<"pizza">
//  | M.Never
// >

Exluding a union returns the intersection of the exclusions of all elements, applied separately:

type Excluded = M.Exclude<
  M.Enum<42 | "pizza" | true>,
  M.Union<
    | M.Primitive<number>
    | M.Primitive<boolean>
  >
>;
// => M.Enum<"pizza">

🚧 Type constraints

To prevent errors, meta-types inputs are validated against type constraints:

type Invalid = M.Array<
  string // <= ❌ Meta-type expected
>;

If you need to use them, all type constraints are also exported:

Meta-typeType constraint
M.AnyM.AnyType = M.Any
M.NeverM.NeverType = M.Never
M.ConstM.ConstType = M.Const<any>
M.EnumM.EnumType = M.Enum<any>
M.PrimitiveM.PrimitiveType = M.Primitive<null \| boolean \| number \| string>
M.ArrayM.ArrayType = M.Array<M.Type>
M.TupleM.TupleType = M.Tuple<M.Type[], M.Type>
M.ObjectM.ObjectType = M.Object<Record<string, M.Type>, string, M.Type>
M.UnionM.UnionType = M.Union<M.Type>
-M.Type = Union of the above

✂️ Unsafe types and methods

In deep and self-referencing computations like in json-schema-to-ts, type constraints can become an issue, as the compiler may not be able to confirm the input type validity ahead of usage.

type MyArray = M.Array<
  VeryDeepTypeComputation<
    ...
  > // <= 💥 Type constraint can break
>

For such cases, ts-algebra exposes "unsafe" types and methods, that behave the same as "safe" ones but removing any type constraints. If you use them, beware: The integrity of the compiling is up to you 😉

SafeUnsafe
M.Any-
M.Never-
M.Const-
M.Enum-
M.PrimitiveM.$Primitive
M.ArrayM.$Array
M.TupleM.$Tuple
M.ObjectM.$Object
M.UnionM.$Union
M.ResolveM.$Resolve
M.IntersectM.$Intersect
M.ExcludeM.$Exclude