Skip to content

justinyaodu/caketype

Repository files navigation

caketype

Type-safe JSON validation? Runtime type-checking? Piece of cake.

Installation | Getting Started | Quick Reference | API Reference

Installation

Install the caketype package:

npm i caketype

caketype has only been thoroughly tested on TypeScript 4.9+, but it seems to work in 4.8, and 4.7 might still work too.

Getting Started

In TypeScript, types describe the structure of your data:

type Person = {
  name: string;
  age?: number;
};

const alice: Person = { name: "Alice" };

This helps us find type errors in our code:

const bob: Person = {};
// Property 'name' is missing.

However, TypeScript types are removed when your code is compiled. If you're working with parsed JSON or other unknown values at runtime, all bets are off:

const bob: Person = JSON.parse("{}");
// no type errors, and no exceptions at runtime

This is a problem: the rest of our code assumes that bob is a Person, but at runtime, the name property is missing. In order for our code to be type-safe, we need to check whether the parsed JSON object matches the Person type at runtime.

Wouldn't it be great if we could write something like this instead?

const alice = Person.as(JSON.parse('{"name": "Alice"}'));
// OK

const bob = Person.as(JSON.parse("{}"));
// TypeError: Value does not satisfy type '{name: string, age?: (number) | undefined}': object properties are invalid.
//   Property "name": Required property is missing.

Let's see how we can achieve this.

Introducing Cakes

A Cake is an object that represents a TypeScript type. Here's our Person type from earlier:

type Person = {
  name: string;
  age?: number;
};

The equivalent Cake looks like this:

import { bake, number, optional, string } from "caketype";

const Person = bake({
  name: string,
  age: optional(number),
});

Cakes are designed to resemble TypeScript types as much as possible, but there are still some differences:

  • A Cake is an object, not a type, so we use const Person instead of type Person. Giving the Cake the same name as the type is not required, but it makes imports more convenient, because importing Person in other files will import both the type and the Cake.
  • The bake function takes an object that looks like a TypeScript type, and creates a Cake.
  • Here, string and number refer to built-in Cakes, not types.
  • To indicate that a property is optional, we use optional instead of ?.

Runtime Type-Checking with Cakes

Cake.as returns the value if it satisfies the Cake's type, and throws a TypeError otherwise:

const alice = Person.as({ name: "Alice" });
// OK

const bob = Person.as({});
// TypeError: Value does not satisfy type '{name: string, age?: (number) | undefined}': object properties are invalid.
//   Property "name": Required property is missing.

Cake.is returns whether the value satisfies the Cake's type:

const alice = JSON.parse('{"name": "Alice"}');

if (Person.is(alice)) {
  // here, the type of alice is Person
  console.log(alice.name);
}

Lastly, Cake.check returns the validated value or a CakeError, wrapped in a Result:

const result = Person.check(JSON.parse('{"name": "Alice"}'));
if (result.ok) {
  // result.value is a Person
  const alice = result.value;
  console.log(alice.name);
} else {
  // result.error is a CakeError
  console.error(result.error.toString());
}

Lenient Type-Checking

By default, runtime type-checking is stricter than TypeScript's static type-checking. For example, TypeScript allows objects to contain excess properties that are not declared in their type:

const carol = { name: "Carol", lovesCake: true };

const person: Person = carol;
// TypeScript allows this, even though lovesCake
// is not declared in the Person type

However, the strict type-checking used by Cake.as, Cake.is, and Cake.check does not allow excess properties:

Person.as(carol);
// TypeError: Value does not satisfy type '{name: string, age?: (number) | undefined}': object properties are invalid.
//   Property "lovesCake": Property is not declared in type and excess properties are not allowed.

To allow excess properties (and match TypeScript's static type-checking more closely in other ways), use the asShape, isShape, and checkShape methods instead:

Person.asShape(carol);
// { name: "Carol", lovesCake: true }

caketype is strict by default, under the assumption that developers should only opt-in to lenient type-checking when necessary. This has two benefits:

  1. Runtime type-checking is often used to validate untrusted parsed JSON values from network requests. Strict type-checking is typically more appropriate for this use case.
  2. It's easier to find and fix bugs caused by excessively strict type-checking, because values that should be okay will produce visible type errors instead. If the type-checking is too lenient, values that should produce type errors will be considered okay, which could have unexpected effects in other parts of your codebase.

Linking TypeScript Types to Cakes

If you have a TypeScript type and a corresponding Cake, you can link them by adding a type annotation to the Cake:

const Person: Cake<Person> = bake(...);

This ensures that the Cake always represents the specified type exactly. If you change the type without updating the Cake, or vice versa, you'll get a TypeScript type error:

type Person = {
  name: string;
  lovesCake: boolean;
};

const Person: Cake<Person> = bake({ name: string });
// Property 'lovesCake' is missing in type '{ name: string; }' but required in type 'Person'.

Inferring TypeScript Types from Cakes

If you want, you can also delete the existing definition of the Person type, and infer the Person type from its Cake:

import { Infer } from "caketype";

type Person = Infer<typeof Person>;
// { name: string, age?: number | undefined }

Creating Complex Cakes

More complex types, with nested objects and arrays, are also supported:

type Account = {
  person: Person;
  friends: string[];
  settings: {
    sendNotifications: boolean;
  };
};

const Account = bake({
  person: Person,
  friends: array(string),
  settings: {
    sendNotifications: boolean,
  },
});
  • We can refer to the existing Person Cake when defining the Account Cake.
  • The array helper returns a Cake that represents arrays of the given type.
  • Nested objects don't require any special syntax.

See the Quick Reference to create Cakes for even more types, like literal types and unions.

Quick Reference

TypeScript Type Cake

Built-in named types:

any
boolean
bigint
never
number
string
symbol
unknown

Import the corresponding Cake:

import { number } from "caketype";

number.is(7); // true

See any, boolean, bigint, never, number, string, symbol, and unknown.

An object type:

type Person = {
  name: string;
  age?: number;
};

Use bake to create the corresponding Cake:

import { bake, number, optional, string } from "caketype";

const Person = bake({
  name: string,
  age: optional(number),
});

Person.is({ name: "Alice" }); // true

An array:

type Numbers = number[];

Use array:

import { array, number } from "caketype";

const Numbers = array(number);

Numbers.is([2, 3]); // true
Numbers.is([]); // true

A union:

type NullableString = string | null;

Use union:

import { string, union } from "caketype";

const NullableString = union(string, null);

NullableString.is("hello"); // true
NullableString.is(null); // true

A literal type:

true
7
"hello"
null
undefined

You can use literal values directly in most contexts. This Cake represents a union of string literals:

import { union } from "caketype";

const Color = union("red", "green", "blue");

And this Cake represents a discriminated union. Note the use of const assertions (as const), which are needed to infer the more specific literal types:

import { number, string, union } from "caketype";

const Operation = union(
  {
    operation: "get",
    id: string,
  } as const,
  {
    operation: "set",
    id: string,
    value: number,
  } as const
);

Use bake if you actually need a Cake instance that represents a literal type:

import { bake } from "caketype";

const seven = bake(7);

seven.is(7); // true
seven.is(8); // false

API Reference


BAKING


bake

Create a Cake from a Bakeable type definition.

const Person = bake({
  name: string,
  age: optional(number),
});

Person.is({ name: "Alice" }); // true

Use Infer to get the TypeScript type represented by a Cake:

type Person = Infer<typeof Person>;
// { name: string, age?: number | undefined }

const bob: Person = { name: "Bob", age: 42 };

Bakeable

A convenient syntax for type definitions. Used by bake.

type Bakeable = Cake | Primitive | ObjectBakeable;

type ObjectBakeable = {
  [key: string | symbol]: Bakeable | OptionalTag<Bakeable>;
};

Baked

The return type of bake for a given Bakeable.


CAKES


Cake

Represent a TypeScript type at runtime.

abstract class Cake<in out T = any>

T: The TypeScript type represented by this Cake.

See bake to create a Cake.


Cake.as

Return the provided value if it satisfies the type represented by this Cake, and throw a TypeError otherwise.

Cake<T>.as(value: unknown): T;

Using the built-in number Cake:

number.as(3); // 3

number.as("oops");
// TypeError: Value does not satisfy type 'number'.

The default type-checking behavior is stricter than TypeScript, making it suitable for validating parsed JSON. For example, excess object properties are not allowed.

See Cake.asShape for more lenient type-checking.

See Cake.check to return the error instead of throwing it.


Cake.asShape

Like Cake.as, but use lenient type-checking at runtime to match TypeScript's static type-checking more closely.

Excess object properties are allowed with lenient type-checking, but not allowed with strict type-checking:

const Person = bake({ name: string });
const alice = { name: "Alice", extra: "oops" };

Person.asShape(alice); // { name: "Alice", extra: "oops" }

Person.as(alice);
// TypeError: Value does not satisfy type '{name: string}': object properties are invalid.
//   Property "extra": Property is not declared in type and excess properties are not allowed.

Cake.check

Return a Result with the provided value if it satisfies the type represented by this Cake, or a CakeError otherwise.

Cake<T>.check(value: unknown): Result<T, CakeError>;

Using the built-in number Cake:

function square(input: unknown) {
  const result = number.check(input);
  if (result.ok) {
    // result.value is a number
    return result.value ** 2;
  } else {
    // result.error is a CakeError
    console.error(result.error);
  }
}

square(3); // 9

square("oops");
// Value does not satisfy type 'number'.

Result.valueOr can be used to return a default value when the provided value is invalid:

number.check(3).valueOr(0); // 3
number.check("oops").valueOr(0); // 0

Result.errorOr can be used to get the CakeError directly, or a default value if no error occurred:

number.check(3).errorOr(null); // null
number.check("oops").errorOr(null); // <CakeError>

The default type-checking behavior is stricter than TypeScript, making it suitable for validating parsed JSON. For example, excess object properties are not allowed.

See Cake.checkShape for more lenient type-checking.

See Cake.as to throw an error if the type is not satisfied.


Cake.checkShape

Like Cake.check, but use lenient type-checking at runtime to match TypeScript's static type-checking more closely.

Excess object properties are allowed with lenient type-checking, but not allowed with strict type-checking:

const Person = bake({ name: string });
const alice = { name: "Alice", extra: "oops" };

Person.checkShape(alice); // Ok(alice)
Person.check(alice); // Err(<CakeError>)

Cake.is

Return whether a value satisfies the type represented by this Cake.

Cake<T>.is(value: unknown): value is T;

Using the built-in number Cake:

number.is(3); // true
number.is("oops"); // false

This can be used as a type guard for control flow narrowing:

const value: unknown = 7;
if (number.is(value)) {
  // here, value has type 'number'
}

The default type-checking behavior is stricter than TypeScript, making it suitable for validating parsed JSON. For example, excess object properties are not allowed.

See Cake.isShape for more lenient type-checking.


Cake.isShape

Like Cake.is, but use lenient type-checking at runtime to match TypeScript's static type-checking more closely.

Excess object properties are allowed with lenient type-checking, but not allowed with strict type-checking:

const Person = bake({ name: string });
const alice = { name: "Alice", extra: "oops" };

Person.isShape(alice); // true
Person.is(alice); // false

Cake.toString

Return a human-readable string representation of the type represented by this Cake.

const Person = bake({
  name: string,
  age: optional(number),
});

Person.toString();
// {name: string, age?: (number) | undefined}

The string representation is designed to be unambiguous and simple to generate, so it may contain redundant parentheses.

The format of the return value may change between versions.


Infer

type

Get the TypeScript type represented by a Cake.

const Person = bake({
  name: string,
  age: optional(number),
});

type Person = Infer<typeof Person>;
// { name: string, age?: number | undefined }

BUILT-IN CAKES


any

A Cake representing the any type. Every value satisfies this type.

any.is("hello"); // true
any.is(null); // true

See unknown to get the same runtime behavior, but an inferred type of unknown instead of any.


array

Return a Cake representing an array of the specified type.

const nums = array(number);

nums.is([2, 3]); // true
nums.is([]); // true

nums.is(["oops"]); // false
nums.is({}); // false

boolean

A Cake representing the boolean type.

boolean.is(true); // true
boolean.is(1); // false

bigint

A Cake representing the bigint type.

bigint.is(BigInt(5)); // true
bigint.is(5); // false

integer

Like number, but only allow numbers with integer values.

integer.is(5); // true
integer.is(5.5); // false

Constraints are supported as well:

const NonNegativeInteger = integer.satisfying({ min: 0 });
NonNegativeInteger.is(5); // true
NonNegativeInteger.is(-1); // false

See number.satisfying.


never

A Cake representing the never type. No value satisfies this type.

never.is("hello"); // false
never.is(undefined); // false

number

A Cake representing the number type.

number.is(5); // true
number.is("5"); // false

Although typeof NaN === "number", this Cake does not accept NaN:

number.is(NaN); // false
number.as(NaN); // TypeError: Value is NaN.

number.satisfying

Only allow numbers that satisfy the specified constraints.

const Percentage = number.satisfying({ min: 0, max: 100 });

Percentage.as(20.3); // 20.3

Percentage.as(-1);
// TypeError: Number is less than the minimum of 0.

Percentage.as(101);
// TypeError: Number is greater than the maximum of 100.

The min and max are inclusive:

Percentage.as(0); // 0
Percentage.as(100); // 100

Multiples of two:

const Even = number.satisfying({ step: 2 });

Even.as(-4); // -4

Even.as(7);
// TypeError: Number is not a multiple of 2.

Multiples of two, with an offset of one:

const Odd = number.satisfying({ step: 2, stepFrom: 1 });

Odd.as(7); // 7

Odd.as(-4);
// TypeError: Number is not 1 plus a multiple of 2.

string

A Cake representing the string type.

string.is("hello"); // true
string.is(""); // true

string.satisfying

Only allow strings that satisfy the specified constraints.

const NonEmptyString = string.satisfying({ length: { min: 1 } });

NonEmptyString.as("hello"); // "hello"

NonEmptyString.as("");
// TypeError: String length is invalid: Number is less than the minimum of 1.

Here, the length constraint is an object accepted by number.satisfying; it can also be a number indicating the exact length, or a Cake.

Strings matching a regular expression (use ^ and $ to match the entire string):

const HexString = string.satisfying({ regex: /^[0-9a-f]+$/ });

HexString.as("123abc"); // "123abc"

HexString.as("oops");
// TypeError: String does not match regex /^[0-9a-f]+$/.

symbol

A Cake representing the symbol type.

symbol.is(Symbol.iterator); // true
symbol.is(Symbol("hi")); // true

union

Return a Cake representing a union of the specified types.

Union members can be existing Cakes:

// like the TypeScript type 'string | number'
const StringOrNumber = union(string, number);

StringOrNumber.is("hello"); // true
StringOrNumber.is(7); // true
StringOrNumber.is(false); // false

Union members can also be primitive values, or any other Bakeables:

const Color = union("red", "green", "blue");
type Color = Infer<typeof Color>; // "red" | "green" | "blue"

Color.is("red"); // true
Color.is("oops"); // false

unknown

A Cake representing the unknown type. Every value satisfies this type.

unknown.is("hello"); // true
unknown.is(null); // true

See any to get the same runtime behavior, but an inferred type of any instead of unknown.


TAGS


optional

Used to indicate that a property is optional.

const Person = bake({
  name: string,
  age: optional(number),
});

type Person = Infer<typeof Person>;
// { name: string, age?: number | undefined }

OptionalTag

Returned by optional.


CAKE ERRORS


CakeError

Represent the reason why a value did not satisfy the type represented by a Cake.


CakeError.throw

Throw a TypeError created from this CakeError.

error.throw();
// TypeError: Value does not satisfy type '{name: string}': object properties are invalid.
//   Property "name": Required property is missing.

CakeError.toString

Return a human-readable string representation of this CakeError.

console.log(error.toString());
// Value does not satisfy type '{name: string}': object properties are invalid.
//   Property "name": Required property is missing.

COMPARISON UTILITIES


sameValueZero

Return whether two values are equal, using the SameValueZero equality algorithm.

This has almost the same behavior as ===, except it considers NaN equal to NaN.

sameValueZero(3, 3); // true
sameValueZero(0, false); // false
sameValueZero(0, -0); // true
sameValueZero(NaN, NaN); // true

MAP UTILITIES

Utility functions for manipulating Maps, WeakMaps, and other MapLikes.

These functions can be imported directly, or accessed as properties of MapUtils:

const nestedMap: Map<number, Map<string, number>> = new Map();

import { deepSet } from "caketype";
deepSet(nestedMap, 3, "hi", 7); // Map { 3 -> Map { "hi" -> 7 } }

// alternatively:
import { MapUtils } from "caketype";
MapUtils.deepSet(nestedMap, 3, "hi", 7); // Map { 3 -> Map { "hi" -> 7 } }

MapLike

Common interface for Maps and WeakMaps.

interface MapLike<K, V> {
  delete(key: K): boolean;
  get(key: K): V | undefined;
  has(key: K): boolean;
  set(key: K, value: V): MapLike<K, V>;
}

deleteResult

Like Map.delete, but return an Ok with the value of the deleted entry, or an Err if the entry does not exist.

const map: Map<number, string> = new Map();
map.set(3, "hi");

deleteResult(map, 3); // Ok("hi")
// map is empty now

deleteResult(map, 3); // Err(undefined)

getResult

Like Map.get, but return an Ok with the retrieved value, or an Err if the entry does not exist.

const map: Map<number, string> = new Map();
map.set(3, "hi");

getResult(map, 3); // Ok("hi")
getResult(map, 4); // Err(undefined)

getOrSet

If the map has an entry with the provided key, return the existing value. Otherwise, insert an entry with the provided key and default value, and return the inserted value.

const map: Map<number, string> = new Map();
map.set(3, "hi");

getOrSet(map, 3, "default"); // "hi"
// map is unchanged

getOrSet(map, 4, "default"); // "default"
// map is now Map { 3 -> "hi", 4 -> "default" }

See getOrSetComputed to avoid constructing a default value if the key is present.


getOrSetComputed

If the map has an entry with the provided key, return the existing value. Otherwise, use the callback to compute a new value, insert an entry with the provided key and computed value, and return the inserted value.

const map: Map<string, string[]> = new Map();

getOrSetComputed(map, "alice", () => []).push("bob");
// "alice" is not present, so the value [] is computed and returned
// then "bob" is added to the returned array
// map is now Map { "alice" -> ["bob"] }

getOrSetComputed(map, "alice", () => []).push("cindy");
// "alice" is present, so the existing array ["bob"] is returned
// then "cindy" is added to the returned array
// map is now Map { "alice" -> ["bob", "cindy"] }

The callback can use the map key to compute the new value:

const map: Map<string, string[]> = new Map();

getOrSetComputed(map, "alice", (name) => [name]).push("bob");
// "alice" is not present, so the value ["alice"] is computed and returned
// then "bob" is added to the returned array
// map is now Map { "alice" -> ["alice", "bob"] }

See getOrSet to use a constant instead of a computed value.


deepDelete

Like Map.delete, but use a sequence of keys to delete an entry from a nested map.

const map: Map<number, Map<string, number>> = new Map();
map.set(3, new Map());
map.get(3).set("hi", 7);
// map is Map { 3 -> Map { "hi" -> 7 } }

deepDelete(map, 3, "hi"); // true
// map is Map { 3 -> Map {} }

deepDeleteResult(map, 3, "hi"); // false

deepDeleteResult

Like deepDelete, but return an Ok with the value of the deleted entry, or Err if the entry does not exist.

const map: Map<number, Map<string, number>> = new Map();
map.set(3, new Map());
map.get(3).set("hi", 7);
// map is Map { 3 -> Map { "hi" -> 7 } }

deepDeleteResult(map, 3, "hi"); // Ok(7)
// map is Map { 3 -> Map {} }

deepDeleteResult(map, 3, "hi"); // Err(undefined)

deepGet

Like Map.get, but use a sequence of keys to get a value from a nested map.

const map: Map<number, Map<string, number>> = new Map();
map.set(3, new Map());
map.get(3).set("hi", 7);
// map is Map { 3 -> Map { "hi" -> 7 } }

deepGet(map, 3, "hi"); // 7
deepGet(map, 3, "oops"); // undefined
deepGet(map, 4, "hi"); // undefined

deepGetResult

Like deepGet, but return an Ok with the retrieved value, or an Err if the entry does not exist.

const map: Map<number, Map<string, number>> = new Map();
map.set(3, new Map());
map.get(3).set("hi", 7);
// map is Map { 3 -> Map { "hi" -> 7 } }

deepGetResult(map, 3, "hi"); // Ok(7)
deepGetResult(map, 3, "oops"); // Err(undefined)
deepGetResult(map, 4, "hi"); // Err(undefined)

deepHas

Like Map.has, but use a sequence of keys to access a nested map.

const map: Map<number, Map<string, number>> = new Map();
map.set(3, new Map());
map.get(3).set("hi", 7);
// map is Map { 3 -> Map { "hi" -> 7 } }

deepHas(map, 3, "hi"); // true
deepHas(map, 3, "oops"); // false
deepHas(map, 4, "hi"); // false

deepSet

Like Map.set, but use a sequence of keys to access a nested map.

const map: Map<number, Map<string, number>> = new Map();
deepSet(map, 3, "hi", 7);
// map is Map { 3 -> Map { "hi" -> 7 } }

deepSet(map, 4, new Map());
// map is Map { 3 -> Map { "hi" -> 7 }, 4 -> Map {} }

New nested Maps are constructed and inserted as necessary. Thus, all of the nested maps must be Maps specifically.

If your nested maps are not Maps, you can use getOrSetComputed to accomplish the same thing:

const map: WeakMap<object, WeakMap<object, number>> = new WeakMap();
const firstKey = {};
const secondKey = {};
const value = 5;
getOrSetComputed(map, firstKey, () => new WeakMap()).set(secondKey, value);

OBJECT UTILITIES

Utility functions for manipulating objects.

These functions can be imported directly, or accessed as properties of ObjectUtils:

import { merge } from "caketype";
merge({ a: 1 }, { b: 2 }); // { a: 1, b: 2 }

// alternatively:
import { ObjectUtils } from "caketype";
ObjectUtils.merge({ a: 1 }, { b: 2 }); // { a: 1, b: 2 }

Entry

Get the type of an object's entries.

type Person = { name: string; age: number };
type PersonEntry = Entry<Person>;
// ["name", string] | ["age", number]

See entriesUnsound to get these entries from an object at runtime.


EntryIncludingSymbols

Get the type of an object's entries, and include entries with symbol keys too.

const sym = Symbol("my symbol");
type Example = { age: number; [sym]: boolean };
type ExampleEntry = EntryIncludingSymbols<Example>;
// ["age", number] | [typeof sym, boolean]

See entriesIncludingSymbolsUnsound to get these entries from an object at runtime.


entries

Alias for Object.entries with a sound type signature.

See entriesUnsound to infer a more specific type for the entries when all of the object's properties are declared in its type.

See this issue explaining why Object.entries is unsound.


entriesUnsound

Like Object.entries, but use the object type to infer the type of the entries.

This is unsound unless all of the object's own enumerable string-keyed properties are declared in its type. If a property is not declared in the object type, its entry will not appear in the return type, but the entry will still be returned at runtime.

If a property is not enumerable, its entry will not be returned at runtime, even if its entry appears in the return type.


entriesIncludingSymbols

Return the entries of an object's own enumerable properties.

This differs from Object.entries by including properties with symbol keys.

See entriesIncludingSymbolsUnsound to infer a more specific type for the entries when all of the object's properties are declared in its type.


entriesIncludingSymbolsUnsound

Like entriesIncludingSymbols, but use the object type to infer the type of the entries.

This is unsound unless all of the object's own enumerable properties are declared in its type. If a property is not declared in the object type, its entry will not appear in the return type, but the entry will still be returned at runtime.

If a property is not enumerable, its entry will not be returned at runtime, even if its entry appears in the return type.


keys

Alias for Object.keys.

See keysUnsound to infer a more specific type for the keys when all of the object's properties are declared in its type.


keysUnsound

Like Object.keys, but use the object type to infer the type of the keys.

This is unsound unless all of the object's own enumerable string-keyed properties are declared in its type. If a property is not declared in the object type, its key will not appear in the return type, but the key will still be returned at runtime.

If a property is not enumerable, its key will not be returned at runtime, even if its key appears in the return type.


keysIncludingSymbols

Return the string and symbol keys of an object's own enumerable properties.

This differs from Object.keys by including symbol keys, and it differs from Reflect.ownKeys by excluding the keys of non-enumerable properties.

See keysIncludingSymbolsUnsound to infer a more specific type for the keys when all of the object's properties are declared in its type.


keysIncludingSymbolsUnsound

Like keysIncludingSymbols, but use the object type to infer the type of the keys.

This is unsound unless all of the object's own enumerable properties are declared in its type. If a property is not declared in the object type, its key will not appear in the return type, but the key will still be returned at runtime.

If a property is not enumerable, its key will not be returned at runtime, even if its key appears in the return type.


values

Alias for Object.values with a sound type signature.

See valuesUnsound to infer a more specific type for the values when all of the object's properties are declared in its type.

See this issue explaining why Object.values is unsound.


valuesUnsound

Like Object.values, but use the object type to infer the type of the values.

This is unsound unless all of the object's own enumerable string-keyed properties are declared in its type. If a property is not declared in the object type, its value will not appear in the return type, but the value will still be returned at runtime.

If a property is not enumerable, its value will not be returned at runtime, even if its value appears in the return type.


valuesIncludingSymbols

Return the values of an object's own enumerable properties.

This differs from Object.values by including properties with symbol keys.


valuesIncludingSymbolsUnsound

Like valuesIncludingSymbols, but use the object type to infer the type of the values.

This is unsound unless all of the object's own enumerable properties are declared in its type. If a property is not declared in the object type, its value will not appear in the return type, but the value will still be returned at runtime.

If a property is not enumerable, its value will not be returned at runtime, even if its value appears in the return type.


mapValues

Return a new object created by mapping the enumerable own property values of an existing object. This is analogous to Array.map.

See mapValuesUnsound to infer more specific types for the keys and values when all of the object's properties are declared in its type.


mapValuesUnsound

Like mapValues, but use the object type to infer the types of the values and keys.

This is unsound unless all of the object's own enumerable properties are declared in its type. If a property is not declared in the object type, its key and value cannot be type-checked against the function parameter types, but the function will still be called with that key and value at runtime.


lookup

Find the first object with the given key set to a non-undefined value, and return that value. If none of the objects have a non-undefined value, return undefined.

lookup("a", { a: 1 }, { a: 2 }); // 1
lookup("a", { a: undefined }, { a: 2 }); // 2
lookup("a", {}, { a: 2 }); // 2
lookup("a", {}, {}); // undefined

If the objects contain properties that are not declared in their types, the inferred return type could be incorrect. This is because an undeclared property can take precedence over a declared property on a later object:

const aNumber = { value: 3 };
const aString = { value: "hi" };

// property 'value' is not declared in type, but present at runtime
const propertyNotDeclared: {} = aString;

const wrong: number = lookup("value", propertyNotDeclared, aNumber);
// no type errors, but at runtime, wrong is "hi"

merge

Return a new object created by merging the enumerable own properties of the provided objects, skipping properties that are explicitly set to undefined.

merge({ a: 1, b: 2, c: 3 }, { a: 99, b: undefined });
// { a: 99, b: 2, c: 3 }

If the objects contain properties that are not declared in their types, the inferred type of the merged object could be incorrect. This is because an undeclared property can replace a declared property from a preceding object:

const aNumber = { value: 3 };
const aString = { value: "hi" };

// property 'value' is not declared in type, but present at runtime
const propertyNotDeclared: {} = aString;

const wrong: { value: number } = merge(aNumber, propertyNotDeclared);
// no type errors, but at runtime, wrong is { value: "hi" }

Remarks

By default, TypeScript allows optional properties to be explicitly set to undefined. When merging partial objects, it's often desirable to skip properties that are set to undefined in order to use a default value:

const defaults = { muted: false, volume: 20 };
const muted = merge(defaults, { muted: true, volume: undefined });
// { muted: true, volume: 20 }

Object.assign and object spreading do not give the desired result:

const wrong1 = { ...defaults, ...{ muted: true, volume: undefined } };
// { muted: true, volume: undefined }

If the TypeScript flag exactOptionalPropertyTypes is not enabled, then undefined can be assigned to any optional property, and object spreading is unsound:

type Config = { muted: boolean; volume: number };
const overrides: Partial<Config> = { muted: true, volume: undefined };
const wrong2: Config = { ...defaults, ...overrides };

const volume: number = wrong2.volume;
// no type errors, but at runtime, volume is undefined

omit

Return a new object created by copying the enumerable own properties of a source object, but omitting the properties with the specified keys.

See omitLoose to allow keys that are not keyof T.


omitLoose

Return a new object created by copying the enumerable own properties of a source object, but omitting the properties with the specified keys.

The type of the returned object will be inaccurate if any of the keys are not literal strings or unique symbols. For example, providing a key with type string will omit all string-keyed properties from the type of the returned object. This type inference limitation does not affect the runtime behavior.

See omit to restrict the keys to keyof T.


pick

Return a new object created by copying the properties of a source object with the specified keys.

Non-enumerable properties are copied from the source object, but the properties will be enumerable in the returned object.


PRIMITIVES


Primitive

A JavaScript primitive value.

type Primitive = bigint | boolean | number | string | symbol | null | undefined;

isPrimitive

Return whether a value is a Primitive.

isPrimitive(5); // true
isPrimitive([]); // false

stringifyPrimitive

Return a string representation of a Primitive that resembles how it would be written as a literal in source code.

console.log(stringifyPrimitive(BigInt(-27))); // -27n
console.log(stringifyPrimitive("hi\nbye")); // "hi\nbye"
// Symbols are unique, so they cannot be recreated
// by evaluating their string representation:
console.log(stringifyPrimitive(Symbol("apple"))); // Symbol(apple)

RESULTS


Result

Represent the result of an operation that could succeed or fail.

type Result<T, E> = Ok<T> | Err<E>;

This is a discriminated union, where Ok represents a successful result, and Err represents an unsuccessful result.

Using an existing Result:

function showDecimal(input: string): string {
  const result: Result<number, string> = parseBinary(input);
  // result is Ok<number> | Err<string>
  if (result.ok) {
    // result is Ok<number>, so result.value is a number
    return `binary ${input} is decimal ${result.value}`;
  } else {
    // result is Err<string>, so result.error is a string
    return `not a binary number: ${result.error}`;
  }
}

showDecimal("101"); // "binary 101 is decimal 5"
showDecimal("foo"); // "not a binary number: invalid character"
showDecimal(""); // "not a binary number: empty string"

Creating Results:

function parseBinary(input: string): Result<number, string> {
  if (input.length === 0) {
    return Result.err("empty string");
  }
  let num = 0;
  for (const char of input) {
    if (char === "0") {
      num = num * 2;
    } else if (char === "1") {
      num = num * 2 + 1;
    } else {
      return Result.err("invalid character");
    }
  }
  return Result.ok(num);
}

parseBinary("101"); // Ok(5)
parseBinary("foo"); // Err(invalid character)
parseBinary(""); // Err(empty string)

Result.ok

function

Return an Ok with the provided value, using undefined if no value is provided.

Result.ok(5); // Ok(5)
Result.ok(); // Ok(undefined)

Result.err

function

Return an Err with the provided error, using undefined if no error is provided.

Result.err("oops"); // Err(oops)
Result.err(); // Err(undefined)

Result.valueOr

method

Return Ok.value, or the provided argument if this is not an Ok.

Result.ok(5).valueOr(0); // 5
Result.err("oops").valueOr(0); // 0

Result.errorOr

method

Return Err.error, or the provided argument if this is not an Err.

Result.err("oops").errorOr("no error"); // "oops"
Result.ok(5).errorOr("no error"); // "no error"

Result.toString

method

Return a string representation of this Result.

Result.ok(5).toString(); // "Ok(5)"
Result.err({}).toString(); // "Err([object Object])"

Ok

class

The result of a successful operation.

See Result.ok to construct an Ok.


Ok.ok

property

Always true. In contrast, Err.ok is always false.


Ok.value

property

The value returned by the successful operation.


Err

class

The result of an unsuccessful operation.

See Result.err to construct an Err.


Err.ok

property

Always false. In contrast, Ok.ok is always true.


Err.error

property

The value returned by the unsuccessful operation.


TYPE-LEVEL ASSERTIONS

Inspect and assert relationships between types. For example, you can assert that one type extends another, or that two types are equivalent.

This can be used to test complex conditional types and type inference.


Assert

Assert that the type argument is always true, or cause a type error otherwise.

type Assert<T extends true> = never;

Typically used with Equivalent, or another generic type that returns a boolean.

type _pass = Assert<Equivalent<string["length"], number>>;
// OK: Equivalent<string["length"], number> is true

type _fail = Assert<Equivalent<string, number>>;
// Type error: Equivalent<string, number> is false

See AssertExtends to get more specific error messages if you are asserting that one type extends another.


AssertExtends

Assert that T extends U.

type AssertExtends<T extends U, U> = never;

Example

type _pass = AssertExtends<3, number>;
// OK: 3 extends number

type _fail = AssertExtends<number, 3>;
// Type error: 'number' does not satisfy '3'

This behaves like Assert combined with Extends, but it uses generic constraints so you can get more specific error messages from the TypeScript compiler.


Equivalent

If T and U extend each other, return true. Otherwise, return false.

type Equivalent<T, U> = [T] extends [U]
  ? [U] extends [T]
    ? true
    : false
  : false;

Example

type _true = Equivalent<string["length"], number>; // true

type _false = Equivalent<3, number>;
// false: 3 extends number, but number does not extend 3

Extends

If T extends U, return true. Otherwise, return false.

type Extends<T, U> = [T] extends [U] ? true : false;

Example

type _ = Assert<Not<Extends<number, 3>>>;
// OK: number does not extend 3

See AssertExtends to get more specific error messages if you are asserting that one type extends another.


If

If T is true, return U. Otherwise, return V.

type If<T extends boolean, U, V> = T extends true ? U : V;

Example

type _apple = If<true, "apple", "banana">; // "apple"

type _either = If<true | false, "apple", "banana">;
// "apple" | "banana"

Not

Return the boolean negation of the type argument.

type Not<T extends boolean> = T extends true ? false : true;

Example

type _false = Not<true>; // false

type _boolean = Not<boolean>;
// negation of true | false is false | true

UTILITY TYPES


Class

The type of a class (something that can be called with new to construct an instance).

interface Class<T = any, A extends unknown[] = any>

T - Type of the class instances.

A - Type of the constructor arguments.

Example

// Any class.
const a: Class[] = [Date, Array, RegExp];

// A class whose instance type is Date.
const b: Class<Date> = Date;

// A class whose instance type is Date, and whose
// constructor can be called with one number argument.
const c: Class<Date, [number]> = Date;

Changelog


v0.5.0 - 2023-02-10

Added

Changed

  • number no longer accepts NaN (#64)
  • Built-in named Cakes (e.g. boolean) now have type Cake instead of TypeGuardCake (#60)
  • Replaced TypeGuardFailedCakeError with WrongTypeCakeError, which is more general and has a more concise error message (#60)

v0.4.1 - 2023-01-26

Added

Changed

  • Cake<...> type annotations now enforce type equivalence (#56)

v0.4.0 - 2023-01-22

Added

Changed

Removed

  • Cake.asStrict, Cake.checkStrict, and Cake.isStrict, since strict checking is the default behavior now (#50)

v0.3.0 - 2023-01-19

Added


v0.2.0 - 2023-01-11

Added