Skip to content
/ typify Public

More types in your scala, in a very type safe way

License

Notifications You must be signed in to change notification settings

mblink/typify

Repository files navigation

Typify

Typify is a library for parsing and validating poorly-typed data into well-typed data.

While it currently provides support for circe, play-json, and the Any type, new source types can be added easily by implementing a type class for your source type.

Typify also does not prescribe a failure type, so you are able to accumulate parse/validation failures of your own type with whatever data is relevant to your use case.

Validation rules and results are bare shapeless HLists, so shapeless record and HList operations are available to compose and manipulate validations and results. This easily supports re-use of validation rules, and partial parsing of inputs.

It is best understood through an example.

Let's say we want to validate a user, making sure that they have a valid email, age, and an optional session id.

First some imports.

import formless.hlist._
import formless.record._
import typify.{Cursor, CursorHistory, ParseError, Typify}

Now we can create an instance of Typify by specifying the failure type we will use and the type we will parse from.

Typify currently includes support for parsing from Any, circe, and play json. New types can be added by implementing the CanParse typeclass for your desired source type.

Let's use Any for this example.

import typify.parsedany._

case class Fail(reason: String, history: CursorHistory[_])

val tp = new Typify[Fail, Any]
// tp: Typify[Fail, Any] = typify.Typify@2bd9cc3e

We also need to define an implicit function to convert a typify.ParseError to our failure type.

ParseError looks like this

case class ParseError(key: String, error: String)
implicit val parse2Error: ParseError[Any] => Fail = pe => Fail(pe.message, pe.cursor.history)
// parse2Error: Function1[ParseError[Any], Fail] = repl.MdocSession$MdocApp$$Lambda/0x000000b8024b94f8@a5b553a

Now we can define some validation functions. Let's validate an email, an age and an optional session id.

import cats.data.NonEmptyList
import cats.syntax.validated._

val checkEmail = Typify.validate((_: String, s: String, c: Cursor[Any]) => s.validNel[Fail]
  .ensure(NonEmptyList.of(Fail("Email is invalid", c.history)))(_.contains("@")))
// checkEmail: Function1[String, PV[Any, Fail, String]] = typify.Typify$$$Lambda/0x000000b8024c21b0@5924ffa8

val checkAge = Typify.validate((_: String, i: Int, c: Cursor[Any]) => i.validNel[Fail]
  .ensure(NonEmptyList.of(Fail("Too young", c.history)))(_ > 21))
// checkAge: Function1[String, PV[Any, Fail, Int]] = typify.Typify$$$Lambda/0x000000b8024c21b0@1ee9de3b

val checkSessIdF = ((_: String, i: Int, c: Cursor[Any]) => i.validNel[Fail]
  .ensure(NonEmptyList.of(Fail("Invalid session id", c.history)))(_ > 3000))
// checkSessIdF: Function3[String, Int, Cursor[Any], Validated[NonEmptyList[Fail], Int]] = repl.MdocSession$MdocApp$$Lambda/0x000000b8024c4460@3d77bc3f

val checkSessId = Typify.optional(checkSessIdF)
// checkSessId: Function1[String, PV[Any, Fail, Option[Int]]] = typify.Typify$$$Lambda/0x000000b8024c27a0@6d55cc2e

Now we can define in which fields to look for these values under our source value as follows.

val checkPerson = ("email" ->> checkEmail) :: ("age" ->> checkAge) :: ("session" ->> checkSessId) :: HNil
// checkPerson: ::[->>["email", KPV[Any, Fail, String]], ::[->>["age", KPV[Any, Fail, Int]], ::[->>["session", KPV[Any, Fail, Option[Int]]], HNil]]] = typify.Typify$$$Lambda/0x000000b8024c21b0@5924ffa8 :: typify.Typify$$$Lambda/0x000000b8024c21b0@1ee9de3b :: typify.Typify$$$Lambda/0x000000b8024c27a0@6d55cc2e :: HNil

From here we are able to parse a person out of Any using our Typify instance.

import tp.syntax._

val passes: Any = Map("email" -> "foo@bar", "age" -> 22, "session" -> 77777, 3 -> "junk")
// passes: Any = Map(
//   "email" -> "foo@bar",
//   "age" -> 22,
//   "session" -> 77777,
//   3 -> "junk"
// )
val passesNoSess: Any = Map("email" -> "foo@bar", "age" -> 22, 500L -> "extra doesnt matter")
// passesNoSess: Any = Map(
//   "email" -> "foo@bar",
//   "age" -> 22,
//   500L -> "extra doesnt matter"
// )
val failsAtParse: Any = 33
// failsAtParse: Any = 33
val failsAtValidation: Any = Map("email" -> "foo", "session" -> 77777)
// failsAtValidation: Any = Map("email" -> "foo", "session" -> 77777)

val passed = Cursor.top(passes).parse(checkPerson)
// passed: Validated[NonEmptyList[Fail], ::[->>["email", String], ::[->>["age", Int], ::[->>["session", Option[Int]], HNil]]]] = Valid(
//   a = "foo@bar" :: 22 :: None :: HNil
// )
val passedNoSess = Cursor.top(passesNoSess).parse(checkPerson)
// passedNoSess: Validated[NonEmptyList[Fail], ::[->>["email", String], ::[->>["age", Int], ::[->>["session", Option[Int]], HNil]]]] = Valid(
//   a = "foo@bar" :: 22 :: None :: HNil
// )
val failedAtParse = Cursor.top(failsAtParse).parse(checkPerson)
// failedAtParse: Validated[NonEmptyList[Fail], ::[->>["email", String], ::[->>["age", Int], ::[->>["session", Option[Int]], HNil]]]] = Invalid(
//   e = NonEmptyList(
//     head = Fail(
//       reason = "Could not be interpreted as java.lang.String",
//       history = CursorHistory(Vector(MoveTop), Some(NonEmptyVector(DownField(email))))
//     ),
//     tail = List(
//       Fail(
//         reason = "Could not be interpreted as Int",
//         history = CursorHistory(Vector(MoveTop), Some(NonEmptyVector(DownField(age))))
//       )
//     )
//   )
// )
val failedAtValidation = Cursor.top(failsAtValidation).parse(checkPerson)
// failedAtValidation: Validated[NonEmptyList[Fail], ::[->>["email", String], ::[->>["age", Int], ::[->>["session", Option[Int]], HNil]]]] = Invalid(
//   e = NonEmptyList(
//     head = Fail(
//       reason = "Email is invalid",
//       history = CursorHistory(Vector(DownField(email), MoveTop), None)
//     ),
//     tail = List(
//       Fail(
//         reason = "Could not be interpreted as Int",
//         history = CursorHistory(Vector(MoveTop), Some(NonEmptyVector(DownField(age))))
//       )
//     )
//   )
// )

Because our validation rules and results are both simply HLists, we can use HList and record operations to compose rules, and do partial validation.

val checkRequiredSess = Typify.validate(checkSessIdF)
// checkRequiredSess: Function1[String, PV[Any, Fail, Int]] = typify.Typify$$$Lambda/0x000000b8024c21b0@50d15db7
val checkPersonWithSession = checkPerson.updateWith("session")(_ => checkRequiredSess)
// checkPersonWithSession: ::[->>["email", KPV[Any, Fail, String]], ::[->>["age", KPV[Any, Fail, Int]], ::[->>["session", Function1[String, PV[Any, Fail, Int]]], HNil]]] = typify.Typify$$$Lambda/0x000000b8024c21b0@5924ffa8 :: typify.Typify$$$Lambda/0x000000b8024c21b0@1ee9de3b :: typify.Typify$$$Lambda/0x000000b8024c21b0@50d15db7 :: HNil

val passedWithSession = Cursor.top(passes).parse(checkPersonWithSession)
// passedWithSession: Validated[NonEmptyList[Fail], ::[->>["email", String], ::[->>["age", Int], ::[->>["session", Int], HNil]]]] = Valid(
//   a = "foo@bar" :: 22 :: 77777 :: HNil
// )
val failedNoSession = Cursor.top(passesNoSess).parse(checkPersonWithSession)
// failedNoSession: Validated[NonEmptyList[Fail], ::[->>["email", String], ::[->>["age", Int], ::[->>["session", Int], HNil]]]] = Invalid(
//   e = NonEmptyList(
//     head = Fail(
//       reason = "Could not be interpreted as Int",
//       history = CursorHistory(Vector(MoveTop), Some(NonEmptyVector(DownField(session))))
//     ),
//     tail = List()
//   )
// )
val passedPartialSession = Cursor.top(passesNoSess).parse(checkPersonWithSession - "session")
// passedPartialSession: Validated[NonEmptyList[Fail], ::[->>["email", String], ::[->>["age", Int], HNil]]] = Valid(
//   a = "foo@bar" :: 22 :: HNil
// )