npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

shulk

v0.15.0

Published

Attempt to bring functionnal programming concepts to TypeScript

Downloads

90

Readme

Shulk

Write beautiful code that won't crash.

Documentation website

Description

Shulk is an opinionated TypeScript library that enhances your TypeScript code by providing a typesafe match expression, monads, and a better way to handle states and polymorphism.

Get started

npm i shulk
# OR
yarn add shulk
# OR
bun add shulk

Table of contents

Pattern matching

Why: Every execution path should be handled

In addition to being syntactically disgraceful, TypeScript switch/case statements are not safe, as the TypeScript compiler will not let you know that you forgot to handle some execution paths.

This can cause errors, or even mistakes in your business logic.

Use match

You can use the match expression to return a certain value or execute a certain function when the input matches a certain value.

When using match, the compiler will force you to be exhaustive, reducing chances that your code has unpredictable behaviors, making it way safer.

Let's take a look at a simple example:

import { match } from 'shulk'

type Pet = 'cat' | 'dog' | 'hamster'
let pet: Pet = 'cat'

const toy = match(pet).with({
	cat: 'plastic mouse',
	dog: 'bone',
	hamster: 'wheel',
})
console.log(toy) // > "plastic mouse"

Note that you don't have to write a specific path for every value.

Every value must be handled one way or another, but you can use _otherwise to handle all the other cases in the same way.

function howManyDoIHave(pet: Pet) {
	return match(pet).with({
		cat: 3,
		_otherwise: 0,
	})
}

console.log(howManyDoIHave('cat')) // > 3
console.log(howManyDoIHave('dog')) // > 0
console.log(howManyDoIHave('hamster')) // > 0

Now, let's try to execute lambdas, by using the case method:

function makeSound(pet: Pet) {
	return match(pet)
		.returnType<void>()
		.case({
			cat: (val) => console.log(`${val}: meow`),
			dog: () => console.log(`${val}: bark`),
			hamster: () => console.log(`${val}: squeak`),
		})
}

console.log(makeSound('cat')) // > "cat: meow"

Match numbers

When matching numbers, you can create a case for a specific number, but you can also create a case for numbers within a range!

// When provided an hour in format 24, the following function returns:
// - 'Night' when hour is between 0 and 4
// - 'Morning' when hour is between 5 and 11
// - 'Noon' when hour is 12
// - 'Afternoon' when hour is between 13 and 18
// - 'Evening' when hour is between 18 and 23
// - 'Not a valid hour' if hour didn't match any case
function hourToPeriod(hour: number) {
	return match(hour).with({
		'0..4': 'Night',
		'5..11': 'Morning',
		12: 'Noon',
		'13..18': 'Afternoon',
		'18..23': 'Evening',
		_otherwise: 'Not a valid hour',
	})
}

Polymorphism and state machines

Why: OOP has a problem

Let's try to model a Television using classic OOP. We want to know if the Television is on or off, and what channel it is displaying.

class Television {
	isOn: boolean
	currentChannel: number
}

There is a problem though: a Television that is currently off can't display anything, and so shouldn't have a currentChannel value.

We could just write a getter that throws or return null if this.isOn == false, but either way it's kind of awkward, as it would only be a verification at runtime.

Shulk unions allows you to make invalid states like this irrepresentable in the compiler, thus making your code safer.

Use unions

Unions are tagged unions of types representing immutable data, hugely inspired by Rust's enums.

Let's rewrite our Television model with Shulk unions:

import { union } from 'shulk'

const Television = union<{
	On: { currentChannel: number }
	Off: {}
}>()
type Television = InferUnion<typeof Television>

So, we just created a model with 2 states: the Television can be On and have a currentChannel property, or it can be Off and have no property.

The Television type we declared here can be transcribed to:

type Television = {
	On: { currentChannel: number; _state: 'On' }
	Off: { _state: 'Off' }
	any: { currentChannel: number; _state: 'On' } | { _state: 'Off' }
}

Let's use our Television:

const onTV: Television['On'] = Television.On({ currentChannel: 12 })
console.log(onTV.currentChannel) // > 12

const offTV: Television['Off'] = Television.Off({})
console.log(offTV.currentChannel) // > error TS2339: Property 'currentChannel' does not exist on type '{ _state: "Off"}'

You can match unions!

Guess what? You can evaluate unions in a match expression, simply by using the tag of each variant. It will even infer the correct type when using the case method!

match(myTV).with({
	On: 'TV is on!',
	Off: 'TV is off!',
})

match(myTV).case({
	On: (tv: Television['On']) => 'TV is on!',
	Off: (tv: Television['Off']) => 'TV is off!',
})

Error handling

Why: try/catch is unsafe

To handle errors (or exceptions), most languages have implemented the try/catch instruction, which is wacky in more ways than one.

First, it's syntactically strange. We are incitated to focus on the successful execution path, as if the catch instruction was 'just in case something bad might happen'. We never write code like this in other situation. We never assume a value is equal to another, we first check that assumption in an if or a match. In the same logic, we should never assume that our code will always work.

Moreover, in some languages such as TypeScript, we can't even declare what kind of error we can throw, making the type safety in a catch block simply non-existent.

The solution: Use the Result monad

The Result monad is a generic type (but really an union under the hood) that will force you to handle errors by wrapping your return types.

type Result<ErrType, OkType>

Let's make a function that divides 2 number and can return an error:

import { Result, Ok, Err } from 'shulk'

function divide(dividend: number, divisor: number): Result<string, number> {
	if (divisor == 0) {
		return Err('Cannot divide by 0!')
	}
	return Ok(dividend / divisor)
}

// We can then handle our Result in a few different ways

// unwrap() is unsafe as it will throw the Error state, but can be useful for prototyping
divide(2, 2).unwrap() // 1
divide(2, 0).unwrap() // Uncaught Cannot divide by 0!

// expect() throws a custom message when it encounters an error state
// Like unwrap(), you shoudn't use it in a production context
divide(2, 2).expect('Too bad!') // 1
divide(2, 0).expect('Too bad!') // Uncaught Too bad!

// unwrapOr() will return the provided default value when encountering an error state
// It is safe to use in a production context, as the program cannot crash
divide(2, 2).unwrapOr('Not a number') // 1
divide(2, 0).unwrapOr('Not a number') // "Not a number"

// isOk() will return true if the Result has an Ok state
// When true, the compiler will infer that val has an OkType
divide(2, 2).isOk() // true
divide(2, 0).isOk() // false

// isErr() will return true if the Result has an Err state
// When true, the compiler will infer that val has an ErrType
divide(2, 2).isErr() // false
divide(2, 0).isErr() // true

// The val property contains the value returned by the function
// It is safe to use
divide(2, 2).val // 1
divide(2, 0).val // "Cannot divide by 0!"

// map() takes a function as an argument and return its value wrapped in an Ok state, or an Err state
divide(2, 2)
	.map((res) => res.toString())
	.unwrap() // "1"
divide(2, 0)
	.map((res) => res.toString())
	.unwrap() // Uncaught Cannot divide by 0!

// flatMap() takes a function that returns a Result, and return its value
divide(2, 2)
	.flatMap((res) => Ok(res.toString()))
	.unwrap() // "1"
divide(2, 0)
	.flatMap((res) => Ok(res.toString()))
	.unwrap() // Uncaught Cannot divide by 0!

// flatMapAsync() takes a function that returns a Result, and return its value in a Promise
divide(2, 2).flatMap((res) => Ok(res.toString())) // Promise
divide(2, 0).flatMap((res) => Ok(res.toString())) // Promise

// filter() evaluates a condition and returns a new Result
divide(2, 2).filter(
	(res) => res == 1,
	() => new Error('Result is not 1'),
) // 1
divide(4, 2).filter(
	(res) => res == 1,
	() => 'Result is not 1',
) // "Result is not 1"

// filterType() evaluates a condition and returns a new Result wrapping the new type
divide(2, 2).filterType(
	(res): res is number => res == 1,
	() => new Error('Result is not 1'),
) // 1
divide(4, 2).filterType(
	(res): res is number => res == 1,
	() => 'Result is not 1',
) // "Result is not 1"

Result and pattern matching

Result is an union, which means you can handle it with match.

match(divide(2, 2)).case({
	Err: () => console.log('Could not compute result'),
	Ok: ({ val }) => console.log('Result is ', val),
})

Optional value handling

Why: the Billion Dollar Mistake

Of all languages, TypeScript is far from being the one with the worst null implementation, even if it still is evaluated as an "object".

You will never get a NullPointerException, but it won't always make your code safer, as you're not forced to handle it explicitely in some situations.

The solution: Use the Maybe monad

The Maybe monad is a generic type (and an union under the hood) which can has 2 states: Some (with a value attached), and None (with no value attached).

Let's take our divide function from the previous sanction, but this time we will return no value when confronted to a division by 0:

import { Maybe, Some, None } from 'shulk'

function divide(dividend: number, divisor: number): Maybe<number> {
	if (divisor == 0) {
		return None()
	} else {
		return Some(dividend / divisor)
	}
}

// We can then handle our Result in a few different ways

// unwrap() is unsafe as it will throw if confronted to the None state, but can be useful for prototyping
divide(2, 2).unwrap() // 1
divide(2, 0).unwrap() // Uncaught Error: Maybe is None

// expect() throws a custom message when it encounters an error state
// Like unwrap(), you shoudn't use it in a production context
divide(2, 2).expect('Too bad!') // 1
divide(2, 0).expect('Too bad!') // Uncaught Too bad!

// unwrapOr() will return the provided default value when encountering a None state
// It is safe to use in a production context, as the program cannot crash
divide(2, 2).unwrapOr('Not a number') // 1
divide(2, 0).unwrapOr('Not a number') // "Not a number"

// map() takes a function as an argument and return its value wrapped in a Some state, or a None state
divide(2, 2)
	.map((res) => res.toString())
	.unwrap() // "1"
divide(2, 0)
	.map((res) => res.toString())
	.unwrap() // Uncaught Error: Maybe is None

// flatMap() takes a function that returns a Maybe as an argument, and return its value
divide(2, 2)
	.flatMap((res) => Some(res.toString()))
	.unwrap() // "1"
divide(2, 0)
	.flatMap((res) => Some(res.toString()))
	.unwrap() // Uncaught Error: Maybe is None

// toResult() maps the Maybe to a Result monad
divide(2, 2).toResult(() => 'Cannot divide by 0') // Result<string, number>

Maybe and pattern matching

Maybe is an union, which means you can handle it with match.

match(divide(2, 2)).case({
	None: () => console.log('Could not compute result'),
	Some: ({ val }) => console.log('Result is ', val),
})

Handle loading

Use the Loading monad

The Loading monad has 3 states: Pending, Failed, Done.

Let's use the Loading monad in a Svelte JS application:

<script lang="ts">
    import { Loading, Result, Pending, Failed, Done } from 'shulk'

    let loading: Loading<Error, string> = Pending()

	function OnMount() {
		const res: Result<Error, string> = await doSomething()

		loading = match(res)
			.returnType<Loading<Error, string>>()
			.case({
				Err: ({ val }) => Failed(val),
				Ok: ({ val }) => Done(val)
			})
	}
</script>

{#if loading._state == 'Loading'}
    <Loader />
{:else if loading._state == 'Done'}
    <p>{loading.val}</p>
{:else}
    <p color="red">{loading.val}</p>
{/if}

Loading and pattern matching

Loading is a union, which means you can handle it with match.

match(loading).case({
	Pending: () => console.log('Now loading....'),
	Done: ({ val }) => console.log('Result is ', val),
	Failed: ({ val }) => {
		throw val
	},
})

Procedural programming

Why: Organizing concurrent tasks is a pain

Sometimes you need to launch concurrent processes. Javascript and Typescript have a way for you to that: launching a bunch of Promises, putting them in an array, and finally executing Promise.all() on the array.

This is not bad, but this is not an exceptionnal way of doing this either, as code will quickly get messy.

Use Procedure

Instead, you can use Shulk's Procedure; which allows you to create a pipeline of Result returning Promises, using a nice builder pattern.

const myProcedure = await Procedure.start()
	.sequence(async () => Ok('Hello, world!')) // When a routine is executed, its response is passed down to the next one
	.sequence(async (msg) => Ok(msg.length)) // Here, msg's value is "Hello, world!"
	.parallelize<never, [string, number]>(
		async (length) => Ok(length.toString()),
		async (length) => Ok(length + 1),
	) // Parallelized routines are executed concurrently. When a single one fails, the error is returned, otherwise all the coroutines responses are returned in an array.
	.end() // The procedure will be executed and return type Result<never, [string, number]>

Wrappers

Shulk helps you make your code safer by providing useful tools and structures, but you'll probably have to use unsafe third-party libraries or legacy code at some point.

To help you keep your code safe, Shulk provides wrappers that enable you to transform unsafe functions outputs into safe monads.

resultify

The resultify wrapper takes an unsafe function and return its output in a Result.

You have probably used the JSON.stringify() method in your code before, but did you know that it will throw a TypeError if it encounters a BigInt? Probably not, and you won't learn it from its signature.

It is one of the rare functions of the JS standard library that actually throws an error instead of returning a null or an incorrect value. It is an improvement, but for us who want to build stable and reliable applications, it is not enough.

Let's make JSON.stringify() way safer by using Shulk's resultify wrapper:

import { resultify } from 'shulk'

// With a single line of code, our application becomes much safer
const safeJsonStringify = resultify<TypeError, typeof JSON.stringify>(
	JSON.stringify,
)

// Now the one calling the function knows it can return a string,
// or a TypeError if it fails
const result: Result<TypeError, string> = safeJsonStringify({ foo: BigInt(1) })

maybify

The maybify wrapper takes a function that can return undefined, null, or NaN, and returns its output in a Maybemonad, with the None state representing undefined, null, or NaN.

Let's reuse our JSON.stringify from before, but this time we will want a Maybe instead of a Result.

import { maybify } from 'shulk'

// With a single line of code, our application becomes much safer
const safeJsonStringify = maybify(JSON.stringify)

// Now the one calling the function knows it can return:
// - a Some state containing a string,
// - a None state if there is nothing to return
const maybe: Maybe<string> = safeJsonStringify({ foo: BigInt(1) })