Giter Club home page Giter Club logo

sum-type's Introduction

sum-type

A simple library for complex logic.

  • 🐭 4KB Gzipped!!!
  • 💾 Serializable (perfect for localStorage)
  • 🎁 Powerful OOTB (Functor, Monad, Bifunctor and more!)
  • 🛠 Extensible ( Open specification, data first ✊)
  • 🚀 Makes UI and API code safer, cleaner and more fun.

Live Example

Live Demo

Quick Start

npm install sum-type
// unpkg.com/sum-type
// let T = SumType

import * as T from 'sum-type'

const Loaded = 
	T.either("Loaded")

const loaded = 
	Loaded.Y("Hello World")

const loading = 
	Loaded.N(55)

const render = 
	Loaded.bifold(
		x => `Loading: ${x}%`
		, x => `Loaded: ${x}`
	)

const transform = 
	Loaded.map(
		x => x.toUpperCase()
	)
;
[ render( transform( loaded ) ) //=> 'Loaded: HELLO WORLD'
, render( transform( loading ) ) //=> 'Loading: 55%'
, render( transform( loading ) ) //=> 'LOADING: 55%'
]
.forEach( x => document.write(`<p>${x}</p>`))

Live Demo

Usage

Recommended

Recommended usage is to simply npm install sum-type and import * as T from 'sum-type

Browser / Playground

Checkout all the distributions here: https://unpkg.com/sum-type/dist/

Common.js

If you require('sum-type') it will use the .cjs distribution out of the box

ESM

ESM is the native format for this library. Simply import * as T from 'sum-type'

What is it

Freedom from booleans.

Scenario: You've solving a moderately difficult problem, and there's a degree of data modelling involved. You've got several booleans for tracking loading states, save states, modified states, selected states and on and on.

Oh! and you're tracking all those states for every item in a list separately.

Depending on a specific combination of these boolean flags you need to render differently, talk to the server differently, persist state differently.

It very quicky becomes a mess. We reach for complex tools to help us manage the mess. But instead all we needed was to make it impossible to not traverse every single state.

First step. Create a sum-type for every state we want to track.

import * as S from 'sum-type'

const Data = S.tags('Data', 
	[ 'Deselected'
	, 'Loading'
	, 'Modified'
	, 'Saved'
	]
)

Our type Data has been generated with 4 tags, each has a constructor:

Data.Saved(data)
//=> { type: 'Data', tag: 'Y', value: data }

Data.Deselected()
//=> { type: 'Deselected', tag: 'N' }

Our constructors just tag our data with a label that allows us to build logic on top of it. Normally this label would be stored separately to the data.

Something like:

var selected = false
var data = ...

Which is fine at first, until it looks like this:

var selected = true
var loaded = true
var modified = true
var saved = false
var data = x

We'd model that in sum-type like so:

import * as S from 'sum-type'

const Data = S.tags('Data', 
	[ 'Deselected'
	, 'Loading'
	, 'Modified'
	, 'Saved'
	]
)

const data = Data.Modified(x)

When we want to transform the value of data we can use any number of useful helpers. Like map<Tag>, chain<Tag>, get<Tag>With or get<Tag>Or where <Tag> is one of your tag names. E.g. mapSaved.

const f = Data.mapSaved(
	x => x * 2
)


f( Data.Saved(2) )
// => Data.Saved(4)

f( Data.Modified(1) )
Data.Modified(1)

const g = Data.getModifiedOr(0)

g( Data.Saved(2) )
//=> 0

g( Data.Modified(3) )
//=> 3

🤓 Types generated by sum-type are decorated with a lot of functions for free. Check out lib/decorate.js to see how it all works.

You can also fold all the cases, as every type generated by sum-type is a discrimated union. Which means (in layperson's terms), you'll get a helpful error if you don't account for every possibly tag.

const NoData = S.otherwise(['Selected', 'Loading'])

const f = 
	Data.fold({
		... NoData( () => 'Nothing' )
		,   Saved: x => 'Saved: ' + x
		,   Modified: x => 'Modified: ' + x
	})

f( Data.Loading() )
//=> 'Nothing'

f( Data.Saved('cool') )
//=> 'Saved: Cool'

There's loads of other helpful utilities, and sum-type will guide you with helpful error messages if you make a mistake. That's one of our design goals!

Helpful Errors

If we pass the wrong data structure into our composition, we will get a specific, helpful error message explaining what your type looked like and what that particular method was expecting.

Your stack trace is going to be particularly legible because sum-type internally avoids point free composition and auto currying, and will report errors at the first incorrect invocation.

So even though fold requires 3 calls:

fold (Type) ({ Y: () => 'hi', N: () => 'bye' }) ( Type.Y() )

sum-type will error if any one of those invocations didn't satisfy the required constraints.

🤓 Every error that sum-type yields, is itself a transformation of a sum-type. All the error types are documented in the Errors section

Specification

sum-type differentiates itself from other sum type library by documenting the internal structure used for types and instances of types. This allows you to create your own constructors/transformers in userland. You can store the exact output of a sum-type constructor in a redux-store, localStorage or even a json column in your favourite database.

sum-type does not care where your data came from, just that it adheres to a particular structure.

Ecosystem

Each module listed here adheres to the sum-type specification. That specification is defined at docs/spec.md.

  • superouter A Router that both exposes and internally uses sum-type to model route definitions, validation and more.

Project Goals and Motivations

  • Serializable
  • 0 Dependencies
  • Tiny for frontend usage
  • Avoid pitfalls found in other sum type libraries
  • Helpful Error Messages designed for makers

How does sum-type differ from other libraries in the ecosystem?

sum-type removes the following features because we believe they lead to brittle codebases.

  • placeholder cases
    • replaced by otherwise which is slightly more verbose but way safer and more powerful
  • auto spreading of values in cata/fold
    • sum-type just have 1 value, and that's what get's passed in to a fold
    • use a list, or an object to have an instance carry more values
  • auto curried constructors
    • Functions are manually curried and report errors per invocation
  • prototypes (reference equality checks / instanceof)
    • Makes it hard to share type checkings for data across serialization boundaries and realms (e.g. Electron apps)

sum-type is technically 0KB, it's an idea. You can use sum-type in your codebase without ever running npm install. But this library is only 4kb gzipped, so even the non-idea part is pretty small.

API

These docs are a bit stale as sum-type goes through some API churn.

either

import { either } from 'sum-type'

const Loaded = 
	either('Loaded')

either::Y

a -> Either Y a | N b

either::N

b -> Either Y a | N b

either::map

( a -> c ) -> Either Y c | N b

either::bimap

(( a -> c ), ( b -> d )) -> Either Y c | N d

either::bifold

(( a -> c ), ( b -> c )) -> Either Y a | N b -> c

either::getWith

( c , ( b -> c )) -> Either Y a | N b -> c

either::getOr

c -> Either Y a | N b -> c

either::fold

Type -> { Y: a -> c, N: b -> c } -> tag -> c

either::chain

( a -> Either Y c | N b ) -> Either Y c | N b

toBoolean

Either Y a | N -> boolean

⚠ You should almost always avoid coercing a sum type to a boolean. If you are checking for Y, try .map. If you are checking for N try getOr. Booleans have no context, no associated data, but there's almost always associated data in your model so toBoolean is much like moving from a lossless format to a lossy format.

either::encase

( a -> b ) -> Either Y b | N Error

Takes a potentially unsafe function and decorates it to return an Either where non thrown values are encased in Either.Y and thrown values are encased in Either.N.

maybe

import { maybe } from 'sum-type'

const Selected = 
	maybe('Selected')

maybe::Y

a -> Maybe Y a | N

maybe::N

() -> Maybe Y a | N

maybe::map

( a -> c ) -> Maybe Y c | N

maybe::bimap

(( () -> b ), ( a -> b )) -> Maybe Y b | N

maybe::bifold

(( () -> b ), ( a -> b )) -> Maybe Y a | N -> b

maybe::getWith

( b , ( a -> b )) -> Maybe Y a | N -> b

maybe::getOr

b -> Maybe Y a | N -> b

maybe::fold

Type -> { Y: a -> b, N: () -> b } -> tag -> b

maybe::chain

( a -> Maybe Y b | N ) -> Maybe Y b | N

toBoolean

Maybe Y a | N -> boolean

⚠ You should almost always avoid coercing a sum type to a boolean. If you are checking for Y, try .map. If you are checking for N try getOr. Booleans have no context, no associated data, but there's almost always associated data in your model so toBoolean is much like moving from a lossless format to a lossy format.

maybe::encase

( a -> b ) -> Maybe Y b | N Error

Takes a potentially unsafe function and decorates it to return an Maybe where non thrown values are encased in Maybe.Y and thrown values are represented as Maybe.N(). If the specific error value is relevant try Either.encase instead as it will return your error object in an Either.N structure.

Canonical Either

In the future some functions will return optional values. This library encourages you to define your own but this library exports a pregenerated Either type that can be used canonically as the "real" Either which can be helpful when doing natural transformations and conversions between types and safe and unsafe data.

import { Y, N, getOr } from sum-type

const yes = Y(100)
const no = N()

const f = getOr(0)

f(yes)
// => 100

f(no)
// => 0

tags

import { tags } from 'sum-type'

const Geom = 
	tags ('Geom') (
		['Point' // : {x, y},
		,'Line' // [p1, p2],
		,'Poly' // [p1, p2, rest]
		]
	)

const p1 = Geom.Point({ x:0, y: 0 })

const p2 = p1

const line = Geom.Line([p1, p2])

const poly = Geom.Poly([p1, p2, [p3]])

fold

Type -> { [tag]: a -> b } -> tag -> b

mapAll

Type -> { [tag]: a -> b } -> tag -> Type b

⚠ Both map and chain will skip executing tags when their instance has no .value property (usually determined by their type constructor).

chainAll

Type -> { [tag]: a -> Type b } -> tag -> Type b

⚠ Both map and chain will skip executing tags when their instance has no .value property (usually determined by their type constructor).

But when using map and chain you are still required to pass in a handler for every tag.

It's recommended to use otherwise with map and chain to prefill values that are not relevant to the fold.

otherwise

string[] -> f -> { [key:string]: f }

A helper function for generating folds that are versioned separately to the type definition. It's useful when you want to avoid specifying each clause in a fold without losing type safety or introducing other modelling problems

Read more about otherwise here

const { Y, N } = T.Maybe
const Platform = T.tags ('Platform') (
	['ModernWindows'
	,'XP'
	,'Linux'
	,'Darwin'
	]
)

const rest = T.otherwise([ // renamed
	'ModernWindows',
	'XP',
	'WSL',
	'Linux',
	'Darwin',
])

const windowsGUI = T.otherwise([
	'ModernWindows',
	'XP',
])

const foldWindowsGUI = f => T.mapAll(Platform) ({
	... rest2(N),
	... windowsGUI( () => Y(f()) )
})

const winPing =
	foldWindowsGUI
		( () => 'ping \\t www.google.com' )

winPing( Platform.Darwin() )
// => T.Maybe.N()

winPing( Platform.XP() )
// => T.Maybe.Y('ping \t www.google.com')

Experimental

These functions are likely to change at any moment.

tagName

tagName -> tagName:string

Extract the name of a tag from an instance of a type.

getTags

T -> (tag:string)[]

Returns a list of the tag's for a given type T.

Errors

Below is the source code definition for the internal errors this library throws.

const StaticSumTypeError =
	tags('StaticSumTypeError', [
		, 'ExtraTags' // {extraKeys}
		, 'MissingTags' // {missingKeys}
		, 'InstanceNull' // {T}
		, 'InstanceWrongType' // {T, x}
		, 'InstanceShapeInvalid' // {T, x}
		, 'tag' // {context}
		, 'VisitorNotAFunction' // {context, visitor}
		, 'NotAType' // {context, T}
	])
Error Throws ...
ExtraTags when a fold specifies a visitor for tags that are not of the type.
MissingTags when a fold does not specify a visitor for each tag of the type.
InstanceNull when an argument was expected to be an instance of a sum type but was instead null.
InstanceWrongType when an instance is a valid sum-type but not the specifically expected type for that function.
InstanceShapeInvalid when an instance has the correct type property but an unknown tag property.
VisitorNotAFunction when a function was expected a visitor function but received anything else.
NotAType when a function expected a sum-type type but received anything else.

sum-type's People

Contributors

jaforbes avatar porsager avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

sum-type's Issues

Implement strict case along side existing case.

I think a great way to dog food the new API would be to include it alongside the existing one and then phase out the old one.

There's a few issues I currently have with case that I think could all be solved in one release.

  • case provides a spread of args which leads to brittle code and can break S.K( Nothing ) if the case has no values #6
  • the placeholder leads to code that can easily become unsafe when refactoring models #13
  • A static case that receives the type as the first arg, instead of a case method on each type #6 (we can implement this and then phase out the existing cases if the API works out
  • An enforced name #14
  • I think we should also specify the type signature of the case function so we can type check return types (currently its always Any).

The API would look something like this:

const display_name = 
  Sum.case( 
    'JAForbes/get'
    ,{} //constraints
    ,[User, String] // signature
    ,{ Loaded: prop('user_name')
     , Id: K('loading...')
     , Empty: K('Create an account')
     }
)

display_name( User.Empty() ) //=> 'Create an Account'
display_name( User.Id(id) ) //=> 'loading...'

We'd get type errors if the case function returned the wrong type, we could form constraints with type variables in sum-type definitions.

Named + Docs only. No Type.Class, or Type.Anonymous.

Related #10

Having to provide a name and a documentation link seems verbose and unnecessary, but being forced to makes the library so much more informative when a type error occurs. If someone really doesn't want to have to do this, they can just pass empty strings in for both arguments. But I'm strongly leaning towards having this enforced in the default interface.

Single static case for all unions and pass the entire record instead of a spread of values.

I've found the current case style which spreads args creates brittle code.
Changing types leads to changing a lot of caller code to receive the values in a particular order.

And in the case of record style (which we will move to exclusively #4 ), we are relying on ordered keys which is unspecified in JS.

I think instead there should be a single case function that works on any sum-type instance.

union-type tags instances for quick identification. I'm a little torn if we should continue using this instead of simply verifying something has the correct shape (like sanctuary-def does).

Perhaps we could support both. I'd really like to pass a pojo to a case function and it fires on the correct branch, but I wouldn't to sacrifice the performance that union-types strategy proved out.

Make compatible with latest sanctuary-def (0.9.0)

At least, I believe this is the issue...

To reproduce, try this code:

const RangeError = SumType.Named('RangeError', {
  InvalidMin: [S.Any],
  InvalidMax: [S.Any],
  MinGreaterThanMax:  {min: Number, max: Number}
});

and you should see this error:

TypeError: Invalid values

The argument to ‘RecordType’ must be an object mapping field name to type.

The following mappings are invalid:

  - "0": Any

    at Object.RecordType (/Users/jg/Dropbox/node/messaging/messaging-types/node_modules/sum-type/node_modules/sanctuary-def/index.js:1527:13)

sum-type's current dependency is listed as sanctuary-def ^0.7.0. I tried uprading it just to see if it would work but I got a different error when installing sanctuary-def 0.9.0:

TypeError: Invalid value

NullaryType :: String -> String -> (Any -> Boolean) -> Type
                         ^^^^^^
                           1

1)  function (a) { return a && a['@@type'] == typeName; } :: Function

The value at position 1 is not a member of ‘String’.

Hide state within closure?

Sometimes I find myself cheating and accessing a property without using case. I think I'm being very clever but it usually comes back to bite me, because that property may not exist, or the data model may change in the future and I am not forced to update my assumptions from the past.

I think this would carry significant benefits and guarantees, but it would also make upgrading to the new version quite difficult. I think we may have to improve the case function to be a lot more expressive than it currently is before making this change. But I would really like to make it impossible to reach into the state of the object without a guarantee of a values existence enforced by sanctuary-def.

Remove prototype / method / class support.

It complicates the codebase, it makes it harder to send typed data over the wire, and I've never actually used it in practice.

Instead we can favour static-land's style. Perhaps we can expose an API for conveniently defining static typed methods (backed by sanctuary-def)

Remove placeholder?

Placeholder is convenient, but it's also risky in a dynamic typed language.

If I have a type with 3 cases, I might have some code that uses the placeholder and in this case, it happens to be safe, because all other cases happen to have the same structure.

const X = T.Named('X', {
  a: { value: $String }
  ,b: { value: $Number } 
  ,c: { value: $Number }
})

const num = 
  X.case({
    a: value => parseInt(value)
    _: Just
  })

In this case, at the time of writing, the _ will handle all other cases, and because they are of type number, we assume its ok to write Just.

But what happens if we add a new case to X

const X = T.Named('X', {
  a: { value: $String }
  ,b: { value: $Number } 
  ,c: { value: $Number }
  ,d: { value: $String }
})

Now that placeholder will fail. In this case, sanctuary will tell us there's a problem. But often, case functions aren't type checked and this leads to brittle code.

I think it'd be better to just not have placeholders at all.

I'm not sold on this, but I've run into this problem several times now, and I really question if its worth the risk.

Sanctuary type errors on calling Setup

I am experiencing some problems in setting um sum-type for usage. Using what I assume to be the recommended way of initialization:

import S from 'sanctuary-def';
import Setup from 'sum-type';

const Type = Setup(S,{checkTypes: true, env: S.env})

I only get the following errors from sanctuary-def.

TypeError: ‘def’ applied to the wrong number of arguments def :: String -> StrMap (Array TypeClass) -> NonEmpty (Array Type) -> Function -> Function ^^^^^^ 1 Expected one argument but received four arguments: - "UnionType.Named" - {} - [String, (StrMap Any), Any] - function CreateUnionType(typeName, rawCases, prototype={}){ ...

I can circumvent these errors by setting checkTypes to false, but this still will leave me with an erroneous Type function that doesn't work.

The package versions I used are:
sum-type 0.12.0,
sanctuary-def 0.16.0

Make type constructors uncurried, only support previous of format

Having curried type constructors seems convenient but in practice is a foot gun. You might extend a type but forget to change the caller code, you won't get any errors just a partially applied type.

Also, in practice I've never wanted to partially apply construction. If I ever did want it, I'd probably want it o be explicit and build up a record progressively and then apply it in one hit.

I also think the union-type style constructors that are a spread of the values within the union is a fairly painful API without a static type system. It's also brittle because the caller needs to not only know the required values but their order.

So I think we should only have of constructors, they should be uncurried, and receive an object.
We should make all properties named, just for consistency, so we'd drop this style:

const Point = Type.Named('Point', {
  Point: [Number, Number]
})

for simply

const Point = Type.Named('Point', {
  Point: { x: Number, y: Number }
})

Usually I think "the less names the better" but I think this is a special circumstance.

We'd drop this construction style:

Point.Point(2,2)

And instead only have:

Point.Point({ x: 2, y: 2 })

If someone wanted to manually curry they could do it themselves via:

const point = x => y => Point.Point({x,y})

And it would be far more explicit, and tooling like typescript would be able to follow what is going on there.

Derive Fantasy Land interfaces from signatures

By observing the signatures provided it is conceivably possible to implement some fantasy land methods automatically if the user specifies they want it.

So as a pie in the sky sketch.

const Maybe = 
  Type.derive(
    [Z.Functor, Z.Applicative, Z.Alt]
    , Type.Named('Maybe', {
      Just: {a}
      ,Nothing: {}
    }))

S.map( S.inc, Maybe.Just(0) )
//=> Maybe.Just(1)

We could detect if its not possible with the information or signatures provided for an Algebra to be automatically provided.

Context
GHC DeriveFunctor

Generate inspect/toString for SumType instances

const $ScheduleVersion = T.Named('ScheduleVersion', {
	Empty: {}
	,Id: { schedule_version_id: $UUID }
	,Name: { schedule_priority: $.String }
	,Loaded: [$ScheduleVersionRecord]
})

$ScheduleVersion.Id(someUUID).toString() 
//=> "ScheduleVersion.IdOf({ schedule_version_id: '023124-41232-123123-123123' })"

Related to #4

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.