Need help with morphic-ts?
Click the “chat” button below for chat support from the developer who created it, or find similar developers for support.

About the developer

sledorze
251 Stars 16 Forks MIT License 276 Commits 9 Opened issues

Services available

!
?

Need anything else?

Contributors list

# 94,886
TypeScr...
algebra...
Node.js
MySQL
214 commits
# 153,303
algebra...
Shell
zio
Scala
18 commits
# 71,163
Emacs
zsh-pro...
zsh-the...
fish-sh...
1 commit
# 351,856
iOS
C
fp-ts
stripe
1 commit
# 99,030
TypeScr...
Angular
HTML
angular...
1 commit
# 568,227
Go
Shell
TypeScr...
1 commit
# 161,592
gitlab
Arch Li...
worker-...
isomorp...
1 commit
# 423,536
JavaScr...
jamstac...
static-...
TypeScr...
1 commit

Publish CI

This library addresses the pain of writing and maintaining code for business objects in Typescript

The goal is to increase, in order of importance

  • Correctness
  • Productivity
  • Developer Experience

It is has two side blended into one; generic ADT manipulation AND Generic, customizable and extensible derivations

Two minutes intro

Add the batteries package to your repo

yarn add '@morphic-ts/batteries'

Then summon your first Morph

import { summonFor } from '@morphic-ts/batteries/lib/summoner-BASTJ'

const { summon } = summonFor({}) // Necessary to Specify the config environment (see Config Environment)

export const Person = summon(F => F.interface( { name: F.string(), age: F.number() }, 'Person' ) )

You now have access to everything to develop around this Type

Person.build // basic build function (enforcing correct type)
Person.show // Show from fp-ts
Person.type // io-ts
Person.strictType // io-ts
Person.eq // Eq from fp-ts
Person.lensFromPath // and other optics (optionals, prism, ) from monocle-ts
Person.arb // fast-check
Person.jsonSchema // JsonSchema-ish representation

Note that batteries exposes several summoners:

BASTJ, ESBAST, ESBASTJ, ESBST These naming are derived from the exposed derivations:

  • E(Eq)
  • B(Build)
  • A(Arbitraries from fast-check)
  • S(Show)
  • ST((Strict) ioTs codecs)
  • J(JsonSchema)

Typically, ESBST is best for runtime with lower memory footprint (no fastcheck) and maybe the right default

You can create yours in userland, it's quite easy if you check how it's done in the batteries package

Discriminated, taggedUnion-like models

import { summonFor } from '@morphic-ts/batteries/lib/summoner-ESBASTJ'

const { summon, tagged } = summonFor({})

export const Bicycle = summon(F => F.interface( { type: F.stringLiteral('Bicycle'), color: F.string() }, 'Bicycle' ) )

export const Car = summon(F => F.interface( { type: F.stringLiteral('Car'), kind: F.keysOf({ electric: null, fuel: null, gaz: null }), power: F.number() }, 'Car' ) )

const Vehicle = tagged('type')({ Car, Bicycle })

Now you have access to previously depicted derivation + ADT support (ctors, predicates, optics, matchers,reducers, etc.. see

ADT Manipulation
below)

Use
tag
if your input model does not have a discriminant

in case your existing model do not expose a discriminant, you can use

tag
to enrich at decode time your model; that enrichment won't be serialized on encoding.

For instance for Car, the solution would be:

export const Car = summon(F =>
  F.interface(
    {
      type: F.tag('Car'),
      kind: F.keysOf({ electric: null, fuel: null, gaz: null }),
      power: F.number()
    },
    'Car'
  )
)

Which would accept an input with shape

{
  "kind": "electric",
  "power": 90
}

Beware that using this with

taggedUnion
(or
tagged
construct) is very inefficient on decoding (trying to decode all Morphs one after the other until it finds a matching one)

Want opaque nominal (instead of structural) inferred types

You may use this pattern

import type { AType, EType } from '@morphic-ts/summoners'

const Car_ = summon(F => F.interface( { type: F.stringLiteral('Car'), kind: F.keysOf({ electric: null, fuel: null, gaz: null }), power: F.number() }, 'Car' ) ) export interface Car extends AType {} export interface CarRaw extends EType {} export const Car = AsOpaque(Car_)

We're sorry for the boilerplate, this is a current Typescript limitation but in our experience, this is worth the effort. A snippet is available to help with that in this repo .vscode folder; we recommend using it extensively.

Configurable

As nice as a General DSL solution to specify your Schema is, there's still some specifics you would like to use.

Morphic gives you the ability to change any derivation via an optional config.

For example, we may want to specify how fastcheck should generate some strings:

summon(F => F.array(F.string({ FastCheckURI: arb => arb.filter(s => s.length > 2) })))

Note: this is type guided and type safe, it's not an

any
in disguise

You may provide several Configurations (by indexing by several URI)

Config Environment

Configs are used to override some specific interpreter instances and this is of great value.

But there's a pitfall.

If one wants to use the fastcheck version of a Morph, for testing reasons, but do not want to have fastcheck included in its app, he needs to reinterpret a Morph:

Using

derive
:
const Person = summonESBST(F =>
  F.interface(
    {
      name: F.string,
      birthDate: F.date
    },
    'Person'
  )
)
const PersonARB = Person.derive(modelFastCheckInterpreter)({})

He can also reinterpret using another summoner.

const AnotherPerson = summonESBASTJ(Person) // Reinterpreter using another summoner (thus generating different type classes)

However, it is often desirable to override fastcheck via a config, for instance, to generate realistic arbitration (here the name with alphabetic letters and a min/max length or a birth date in the past).

Doing so means that one needs to add a config for fastcheck when defining the

Person
members Morphs, thus, including the fastcheck lib.

But doing so, the lib is imported outside of tests, which is not desirable.

Config Environment solve this issue by offering the ability to give access to an environment for the config of each interpreter.

The motivation is providing ways to abstract over dependencies used in configs.

We can access an environnement to use in a config like so (here IoTsTypes):

summon(F =>
  F.interface({ a: F.string({ IoTsURI: (x, env: IoTsTypes) => env.WM.withMessage(x, () => 'not ok') }) }, 'a')
)

The environnement type has to be specified at definition site.

To prevent really importing the lib to get it's type (the definition is purely a type), we can rely on type imports from typescript.

import type * as fc from 'fast-check'

The Config also infers the correct Env type and only typechecks correctly if

summon
has been instantiated with correct Env constraints using the
summonFor
constructor.

Creating the summon requires providing (all) the environments a summoner will be able to support.

export const { summon } = summonFor({ IoTsURI: { WM } })

I advise you to use a proxy interface to keep this opaque an lean.

export interface AppEnv {
  IoTsURI: IoTsTypes
}

export const { summon } = summonFor({ IoTsURI: { WM } })

If the underlying Interpreter of

summoner
does not generate a type-class (e.g.
io-ts
), then there is no need to feed it at creation time:
export const { summon } = summonFor({})

This will type-check accordingly.

However the type constraint of the Env will remain in the summoner signature, so that any (re)interpretation from another summoner will thread that constraint; there no compromise on type safety.

The consequence is that any interpreting summoner Env will need to cover all the Env from the source summoner.

This transitive aspect is the necessary condition for correct (re)interpretations.

Define

Summoners now also provide a

define
member in order to help creating Programs (not Morphs).

Those

define
are only constrained by the summoner Algebra (Program), not the summoner TypeClasses. And as such, these can freely be combined with any kind of summoner implementing this Algebra.

They also carry their Config Env constraints.

You can directly create a

Define
instance by using
defineFor
and specifying the algebra (via a program Uri).
defineFor(ProgramNoUnionURI)(F => F.string)

How it works

When you specify a Schema, you're using an API (eDSL implemented using final tagless). This

API
defines a
Program
(your schema) using an
Algebra
(the combinators exposed to do so).

This

Algebra
you're using is actually composed of several
Algebras
merged together, some defines how to encode a
boolean
, some others a
strMap
(string Map), etc..

Then for each possible derivation there's possibly an Ìnterpreter` implementing some Algebras. What Morphic does is orchestrating this machinery for you

This pattern has some interesting properties; it is extensible in both the

Algebra
and the
Interpreter

Generic Derivation

Specify the structure of your Schema only once and automatically has access various supported implementations

Participate into expanding implementation and/or schema capabilities

Example of implementations:

  • Structural equality (via Eq from fp-ts)
  • Validators (io-ts)
  • Schema generation (JsonSchema flavor)
  • Pretty print of data structure (Show from fp-ts)
  • Generators (FastCheck)
  • ...
  • TypeOrm (WIP)

This is not an exhaustive list, because the design of Morphic enables to define more and more

Interpreters
for your
Schemas
(composed of
Algebras
).

ADT Manipulation

Note: ADT behaviour is available via Summoners; however it is also available without the derivation machinery, hence this paragraph, which also applies to summoned Morphs.

ADT stands for

Algebraic Data Types
, this may be strange, just think about it as the pattern to represent your casual Business objects

ADT manipulation support maybe be used without relying on full Morphic objects.

The feature can be used standalone via the

makeADT
function with support for:
  • Smart Ctors
  • Predicates
  • Optics (Arcane name for libraries helping manipulate immutable data structures in FP)
  • Matchers
  • Reducers
  • Creation of new ADTs via selection, exclusion, intersection or union of existing ADTs

Ad-hoc usage via

makeADT
(Morphic's
summon
already does that for you):

Let's define some Types

interface Bicycle {
  type: 'Bicycle'
  color: string
}

interface Motorbike { type: 'Motorbike' seats: number }

interface Car { type: 'Car' kind: 'electric' | 'fuel' | 'gaz' power: number seats: number }

Then build an ADT from them for PROFIT!

// ADT
const Vehicle = makeADT('type')({
  Car: ofType(),
  Motorbike: ofType(),
  Bicycle: ofType()
})

Then you have..

Constructors

Vehicle.of.Bicycle({ color: 'red' }) // type is Car | Motorbike | Bicycle

// as offer a narrowed type Vehicle.as.Car({ kind: 'electric', power: 2, seats: 4 }) // type is Car

Predicates

// Predicate and Refinements
Vehicle.is.Bicycle // (a: Car | Motorbike | Bicycle) => a is Bicycle

// Exist also for several Types const isTrafficJamProof = Vehicle.isAnyOf(['Motorbike', 'Bicycle']) // (a: Car | Motorbike | Bicycle) => a is Motorbike | Bicycle

Matchers

const nbSeats = Vehicle.match({
  Car: ({ seats }) => seats,
  Motorbike: ({ seats }) => seats,
  Bicycle: _ => 1
})

// Alternatively you may use default Vehicle.match( { Car: ({ seats }) => seats, Motorbike: ({ seats }) => seats }, _ => 1 )

// match widens the returned type by contructing a union of all branches result types // Here it is number | 'none' Vehicle.match( { Car: ({ seats }) => seats, Motorbike: ({ seats }) => seats }, _ => 'none' as const )

// A stricter variant enforcing homogeneous return type in branches exists Vehicle.matchStrict({ Car: ({ seats }) => seats, Motorbike: ({ seats }) => seats, Bicycle: _ => 1 })

// Which would error in case of heterogeneous return types, like this: Vehicle.matchStrict({ Car: ({ seats }) => seats, Motorbike: ({ seats }) => seats, Bicycle: _ => 'none' })

Transformers

// You may transform matching a subset
Vehicle.transform({
  Car: car => ({ ...car, seats: car.seats + 1 })
})

Reducers

// Creating a reducer is made as easy as specifying a type
Vehicle.createReducer({ totalSeats: 0 })({
  Car: ({ seats }) => ({ totalSeats }) => ({ totalSeats: totalSeats + seats }),
  Motorbike: ({ seats }) => ({ totalSeats }) => ({ totalSeats: totalSeats + seats }),
  default: _ => identity
})
// Partial reducers are also supported
Vehicle.createPartialReducer({ totalSeats: 0 })({
  Car: ({ seats }) => ({ totalSeats }) => ({ totalSeats: totalSeats + seats }),
  Motorbike: ({ seats }) => ({ totalSeats }) => ({ totalSeats: totalSeats + seats })
})

Selection, Exclusion, Intersection and Union of ADTs

This will help getting unique advantage of Typescript ability to refine Unions

const Motorized = Vehicle.select(['Car', 'Motorbike']) // ADT

const TrafficJamProof = Vehicle.exclude(['Car']) // ADT

const Faster = intersectADT(Motorized, TrafficJamProof) // ADT

const ManyChoice = unionADT(Motorized, TrafficJamProof) // ADT

Optics (via Monocle)

We support lenses, optional, prism pre-typed helpers

Lens example:

const motorizedSeatLens = Motorized.lensFromProp('seats') // Lens

const incSeat = motorizedSeatLens.modify(increment) // (s: Car | Motorbike) => Car | Motorbike

const vehicleSeatOptional = Vehicle.matchOptional({ Motorbike: motorizedSeatLens.asOptional(), Car: motorizedSeatLens.asOptional() // undesired cases can be omitted }) // Optional

Get in touch

The best place to reach out is https://fpchat-invite.herokuapp.com (channel #morphic)

We use cookies. If you continue to browse the site, you agree to the use of cookies. For more information on our use of cookies please see our Privacy Policy.