{"id":13801295,"url":"https://github.com/yakivy/dupin","last_synced_at":"2026-01-21T04:34:25.774Z","repository":{"id":57724216,"uuid":"220068132","full_name":"yakivy/dupin","owner":"yakivy","description":"Minimal, idiomatic, customizable validation Scala library.","archived":false,"fork":false,"pushed_at":"2024-04-02T14:41:45.000Z","size":130,"stargazers_count":43,"open_issues_count":0,"forks_count":2,"subscribers_count":3,"default_branch":"master","last_synced_at":"2025-05-13T11:42:58.795Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"Scala","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/yakivy.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2019-11-06T18:56:13.000Z","updated_at":"2025-01-08T23:25:34.000Z","dependencies_parsed_at":"2024-08-04T00:16:15.787Z","dependency_job_id":null,"html_url":"https://github.com/yakivy/dupin","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/yakivy/dupin","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/yakivy%2Fdupin","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/yakivy%2Fdupin/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/yakivy%2Fdupin/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/yakivy%2Fdupin/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/yakivy","download_url":"https://codeload.github.com/yakivy/dupin/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/yakivy%2Fdupin/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":28626310,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-01-21T02:47:06.670Z","status":"ssl_error","status_checked_at":"2026-01-21T02:45:44.886Z","response_time":86,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.6:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-08-04T00:01:21.311Z","updated_at":"2026-01-21T04:34:25.752Z","avatar_url":"https://github.com/yakivy.png","language":"Scala","funding_links":[],"categories":["Table of Contents","Data Binding and Validation"],"sub_categories":["Data Binding and Validation"],"readme":"## Dupin\n[![Maven Central](https://img.shields.io/maven-central/v/com.github.yakivy/dupin-core_2.12.svg)](https://mvnrepository.com/search?q=dupin)\n[![Sonatype Nexus (Snapshots)](https://img.shields.io/nexus/s/https/oss.sonatype.org/com.github.yakivy/dupin-core_2.13.svg)](https://oss.sonatype.org/content/repositories/snapshots/com/github/yakivy/dupin-core_2.13/)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n\u003ca href=\"https://typelevel.org/cats/\"\u003e\u003cimg src=\"https://typelevel.org/cats/img/cats-badge.svg\" height=\"40px\" align=\"right\" alt=\"Cats friendly\" /\u003e\u003c/a\u003e\n\nDupin is a minimal, idiomatic, customizable validation Scala library.\n\nYou may find Dupin useful if you...\n- want a transparent and composable validation approach\n- need to return something richer than `String` as validation message\n- use effectful logic inside validator (`Future`, `IO`, etc...)\n- like [parse don't validate](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/) style\n- have [cats](https://typelevel.org/cats/) dependency and like their API style\n- need Scala 3, Scala JS or Scala Native support\n\nLibrary is built around two type classes:\n- `Validator[F[_], E, A]` - is a self-sufficient validator for type `A`, represents a function `A =\u003e F[ValidatedNec[E, Unit]]`\n- `Parser[F[_], E, A, B]` - is a parser from type `A` to type `B`, represents a function `A =\u003e F[IorNec[E, B]]`\n\n### Table of contents\n1. [Quick start](#quick-start)\n   1. [Validate](#validate)\n   2. [Parse](#parse)\n2. [Predefined validators](#predefined-validators)\n3. [Message customization](#message-customization)\n4. [Effectful validation](#effectful-validation)\n5. [Custom validating package](#custom-validating-package)\n6. [Complex example](#complex-example)\n7. [Roadmap](#roadmap)\n8. [Changelog](#changelog)\n\n### Quick start\nAdd cats and dupin dependencies to the build file, let's assume you are using sbt:\n```scala\nlibraryDependencies += Seq(\n    \"org.typelevel\" %% \"cats-core\" % \"2.9.0\",\n    \"com.github.yakivy\" %% \"dupin-core\" % \"0.6.1\",\n)\n```\nDescribe the domain:\n```scala\ncase class Name(value: String)\ncase class Member(name: Name, age: Int)\ncase class Team(name: Name, members: Seq[Member])\n```\n\n#### Validate\n\nDefine validators:\n```scala\nimport cats._\nimport dupin.basic.all._\n\n//validator for simple type or value class\nimplicit val nameValidator: BasicValidator[Name] = BasicValidator\n    .root[Name](_.value.nonEmpty, c =\u003e s\"${c.path} should be non empty\")\n\n//idiomatic validator for complex type\nimplicit val memberValidator: BasicValidator[Member] =\n    nameValidator.comapP[Member](_.name) combine\n    BasicValidator.root[Int](\n        a =\u003e a \u003e 18 \u0026\u0026 a \u003c 40,\n        c =\u003e s\"${c.path} should be between 18 and 40\"\n    ).comapP[Member](_.age)\n\n//same validator but with combination helpers for better type resolving\nval alternativeMemberValidator: BasicValidator[Member] = BasicValidator\n    .success[Member]\n    .combineP(_.name)(nameValidator)\n    .combinePR(_.age)(a =\u003e a \u003e 18 \u0026\u0026 a \u003c 40, c =\u003e s\"${c.path} should be between 18 and 40\")\n\n//derived validator\nimplicit val teamValidator: BasicValidator[Team] = BasicValidator\n    .derive[Team]\n    .combineR(_.members.size \u003c= 8, _ =\u003e \"team should be fed with two pizzas!\")\n\n//two stage validator\nval failingTeamValidator: BasicValidator[Team] = teamValidator\n    .andThen(BasicValidator.failure[Team](_ =\u003e \"validation error after heavy computations\"))\n```\nValidate them all:\n```scala\nimport dupin.basic.all._\n\nval validTeam = Team(\n    Name(\"Bears\"),\n    List(\n        Member(Name(\"Yakiv\"), 26),\n        Member(Name(\"Myroslav\"), 31),\n        Member(Name(\"Andrii\"), 25)\n    )\n)\n\nval invalidTeam = Team(\n    Name(\"\"),\n    Member(Name(\"\"), 0) :: (1 to 10).map(_ =\u003e Member(Name(\"Valid name\"), 20)).toList\n)\n\nassert(validTeam.isValid)\nassert(invalidTeam.validate == Validated.invalid(NonEmptyChain(\n    \".members.[0].name should be non empty\",\n    \".members.[0].age should be between 18 and 40\",\n    \".name should be non empty\",\n    \"team should be fed with two pizzas!\",\n)))\nassert(failingTeamValidator.validate(validTeam) == Validated.invalid(NonEmptyChain(\n    \"validation error after heavy computations\",\n)))\nassert(failingTeamValidator.validate(invalidTeam) == Validated.invalid(NonEmptyChain(\n    \".members.[0].name should be non empty\",\n    \".members.[0].age should be between 18 and 40\",\n    \".name should be non empty\",\n    \"team should be fed with two pizzas!\",\n)))\n```\n\n#### Parse\n\nEnrich the domain with raw models to parse:\n```scala\ncase class RawMember(name: String, age: Int)\ncase class RawTeam(name: String, members: List[RawMember])\n```\n\nDefine parsers:\n```scala\nimport cats._\nimport cats.implicits._\nimport dupin.basic.all._\n\n// parser for simple type or value class\nimplicit val nameParser: BasicParser[String, Name] = BasicParser.root[String, Name](\n    Option(_).filter(_.nonEmpty).map(Name.apply),\n    c =\u003e s\"${c.path} should be non empty\",\n)\n\n//idiomatic parser for complex type\nimplicit val memberParser: BasicParser[RawMember, Member] =\n    (\n        nameParser.comapP[RawMember](_.name),\n        BasicParser.idRoot[Int](\n            Option(_).filter(a =\u003e a \u003e 18 \u0026\u0026 a \u003c 40),\n            c =\u003e s\"${c.path} should be between 18 and 40\",\n        ).comapP[RawMember](_.age),\n    )\n        .parMapN(Member.apply)\n\nimplicit val teamParser: BasicParser[RawTeam, Team] =\n    (\n        nameParser.comapP[RawTeam](_.name),\n        memberParser.liftToTraverseCombiningP[List].comapP[RawTeam](_.members),\n    )\n        .parMapN(Team.apply)\n        .andThen(\n            //if you need identity parser that filters out value by condition,\n            //you can simply create a validator and convert it to parser\n            BasicValidator\n                .root[Team](_.members.size \u003c= 8, _ =\u003e \"team should be fed with two pizzas!\")\n                .toParser\n        )\n```\n\nParse them all:\n```scala\nval validTeam = RawTeam(\n    \"Bears\",\n    List(\n        RawMember(\"Yakiv\", 26),\n        RawMember(\"Myroslav\", 31),\n        RawMember(\"Andrii\", 25)\n    )\n)\n\nval invalidTeam = RawTeam(\n    \"\",\n    RawMember(\"\", 0) :: (1 to 10).map(_ =\u003e RawMember(\"Valid name\", 20)).toList\n)\n\nassert(validTeam.parse == Ior.right(Team(\n    Name(\"Bears\"),\n    List(\n        Member(Name(\"Yakiv\"), 26),\n        Member(Name(\"Myroslav\"), 31),\n        Member(Name(\"Andrii\"), 25)\n    )\n)))\nassert(invalidTeam.parse == Ior.left(NonEmptyChain(\n    \".name should be non empty\",\n    \".members.[0].name should be non empty\",\n    \".members.[0].age should be between 18 and 40\",\n)))\n```\n\n### Predefined validators\n\nIt also might be useful to extract and reuse validators for common types. Let's define validators for minimum and maximum `Int` value:\n```scala\nimport dupin.basic.all._\n\ndef min(value: Int) = BasicValidator.root[Int](_ \u003e value, c =\u003e s\"${c.path} should be greater than $value\")\ndef max(value: Int) = BasicValidator.root[Int](_ \u003c value, c =\u003e s\"${c.path} should be less than $value\")\n``` \nAnd since validators can be combined, you can use them to create more complex validators:\n```scala\nimport cats._\nimport dupin.basic.all._\n\nimplicit val memberValidator: BasicValidator[Member] = BasicValidator\n    .success[Member]\n    .combineP(_.age)(min(18) \u0026\u0026 max(40).failureAs(_ =\u003e \"updated validation message\"))\n\nval invalidMember = Member(Name(\"Ada\"), 0)\nval result = invalidMember.validate\n\nassert(result == Validated.invalidNec(\".age should be greater than 18\"))\n```\n\n### Message customization\n\nBut not many real projects use strings as validation messages, for example you want to support internationalization:\n```scala\ncase class I18nMessage(\n    description: String,\n    key: String,\n    params: List[String]\n)\n```\n`BasicValidator[A]` is simply a type alias for `Validator[Id, String, A]`, so you can define own validator type with partially applied builder:\n\n```scala\nimport dupin._\n\ntype I18nValidator[A] = Validator[cats.Id, I18nMessage, A]\nval I18nValidator = Validator[cats.Id, I18nMessage]\n```\nAnd start creating validators with custom messages:\n```scala\nimport cats._\n\nimplicit val nameValidator: I18nValidator[Name] = I18nValidator.root[Name](\n    _.value.nonEmpty,\n    c =\u003e I18nMessage(\n        s\"${c.path} should be non empty\",\n        \"validator.name.empty\",\n        List(c.path.toString())\n    )\n)\n\nimplicit val memberValidator: I18nValidator[Member] = I18nValidator\n    .success[Member]\n    .combinePI(_.name)\n    .combinePR(_.age)(a =\u003e a \u003e 18 \u0026\u0026 a \u003c 40, c =\u003e I18nMessage(\n        s\"${c.path} should be between 18 and 40\",\n        \"validator.member.age\",\n        List(c.path.toString())\n    ))\n```\nValidation messages will look like:\n```scala\nimport dupin.syntax._\n\nval invalidMember = Member(Name(\"\"), 0)\nval result = invalidMember.validate\n\nassert(result == Validated.invalid(NonEmptyChain(\n    I18nMessage(\n        \".name should be non empty\",\n        \"validator.name.empty\",\n        List(\".name\")\n    ),\n    I18nMessage(\n        \".age should be between 18 and 40\",\n        \"validator.member.age\",\n        List(\".age\")\n    )\n)))\n```\n\n### Effectful validation\n\nFor example, you want to allow only a limited list of names and it is stored in the database:\n```scala\nimport scala.concurrent.Future\n\nclass NameService {\n    private val allowedNames = Set(\"Ada\")\n    def contains(name: String): Future[Boolean] =\n        // Emulation of DB call\n        Future.successful(allowedNames(name))\n}\n```\nSo to be able to handle checks that return `Future[Boolean]`, you just need to define your own validator type with partially applied builder:\n```scala\nimport dupin._\nimport scala.concurrent.Future\n\ntype FutureValidator[A] = Validator[Future, String, A]\nval FutureValidator = Validator[Future, String]\n``` \nThen you can create validators with generic DSL (don't forget to import required type classes, as minimum `Functor[Future]`):\n```scala\nimport cats.implicits._\nimport scala.concurrent.Future\n\nval nameService = new NameService\n\nimplicit val nameValidator: FutureValidator[Name] = FutureValidator.rootF[Name](\n    n =\u003e nameService.contains(n.value),\n    c =\u003e s\"${c.path} should be non empty\"\n)\n\nimplicit val memberValidator: FutureValidator[Member] = FutureValidator\n    .success[Member]\n    .combinePI(_.name)\n    .combinePR(_.age)(a =\u003e a \u003e 18 \u0026\u0026 a \u003c 40, c =\u003e s\"${c.path} should be between 18 and 40\")\n```\nValidation result will look like:\n```scala\nimport dupin.syntax._\n\nval invalidMember = Member(Name(\"\"), 0)\nval result: Future[ValidatedNec[String, Member]] = invalidMember.validate\n\nresult.map(r =\u003e assert(r == Validated.invalid(NonEmptyChain(\n    \".name should be non empty\",\n    \".age should be between 18 and 40\"\n))))\n```\n\n### Custom validating package\n\nTo avoid imports boilerplate and isolating all customizations, you can define your own dupin package:\n```scala\npackage object custom extends DupinCoreDsl with DupinSyntax {\n    type CustomValidator[A] = Validator[Future, I18nMessage, A]\n    val CustomValidator = Validator[Future, I18nMessage]\n\n    type CustomParser[A, B] = Parser[Future, I18nMessage, A, B]\n    val CustomParser = Parser[Future, I18nMessage]\n}\n```\nThen you can start using custom validator type with a single import:\n```scala\nimport cats.implicits._\nimport dupin.custom._\n\nval nameService = new NameService\n\nimplicit val nameValidator: CustomValidator[Name] = CustomValidator.rootF[Name](\n    n =\u003e nameService.contains(n.value),\n    c =\u003e I18nMessage(\n        s\"${c.path} should be non empty\",\n        \"validator.name.empty\",\n        List(c.path.toString())\n    )\n)\n\nval validName = Name(\"Ada\")\nval valid: Future[Boolean] = validName.isValid\n\nvalid.map(assert(_))\n```\n\n### Complex example\n\nLet's assume that you need to build a method that receives a list of raw term models (each model is a product of term itself and a list of mistakes that people often make when typing this term, for example: \"calendar\" -\u003e [\"calender\", \"celender\"]) and parses them before saving to the database. Here are some requirements:\n- suggested raw model:\n```scala\ncase class RawTermModel(\n    term: String,\n    mistakes: List[String],\n)\n```\n- term and mistake should be a single word\n- term and mistake should not exist in the database:\n```scala\ntype R[A] = Either[String, A]\ntrait TermRepository {\n    def contains(term: Term): R[Boolean] = ...\n}\n```\n- terms should be unique among other terms in the list\n- mistakes should be unique among other mistakes and terms in the list\n- parsed model should have as minimum one mistake\n- suggested final model:\n```scala\ncase class Term(value: String)\ncase class TermModel(\n    term: Term,\n    mistakes: NonEmptyList[Term],\n)\n```\n- if validation error occurs in term - skip the model and continue parsing\n- if validation error occurs in mistake - skip the mistake only and continue parsing\n- all validation errors should be collected and returned after parsing\n\nSo the parser from `RawTermModel` to `TermModel`, considering the requirements above, will look like:\n```scala\n//validation types to handle repository effect `R`\ntype CustomValidator[A] = Validator[R, String, A]\nval CustomValidator = Validator[R, String]\ntype CustomParser[A, B] = Parser[R, String, A, B]\nval CustomParser = Parser[R, String]\n\n//parsers per requirement:\n\n//term and mistake should be a single word\nval termParser = CustomParser\n    .root[String, Term](\n        Option(_).filter(_.matches(\"\\\\w+\")).map(Term.apply),\n        c =\u003e s\"${c.path}: cannot parse string '${c.value}' to a term\"\n    )\n\n//term and mistake should not exist in the database\nval repositoryTermParser = CustomValidator\n    .rootF[Term](\n        TermRepository.contains(_).map(!_),\n        c =\u003e s\"${c.path}: term '${c.value}' already exists\"\n    )\n    .toParser\n\n//intermediate model to aggregate parsed terms\ncase class HalfParsedTermModel(\n    term: Term,\n    mistakes: List[Term],\n)\n\n//terms should be unique among other terms in the list\nval uniqueTermsParser = CustomParser\n    //define list level context where terms should be unique\n    .idContext[List[HalfParsedTermModel]] { _ =\u003e\n        val validTerms = mutable.Set.empty[Term]\n        CustomValidator\n            .root[Term](validTerms.add, c =\u003e s\"${c.path}: term '${c.value}' is duplicate\")\n            .comapP[HalfParsedTermModel](_.term)\n            .toParser\n            //lift parser to `List` accumulating errors\n            .liftToTraverseCombiningP[List]\n    }\n\n//mistakes should be unique among other mistakes and terms in the list\nval uniqueTermsMistakesParser = CustomParser\n    .idContext[List[HalfParsedTermModel]] { ms =\u003e\n        val validTerms = mutable.Set.from(ms.view.map(_.term))\n        CustomParser\n            .idContext[HalfParsedTermModel] { m =\u003e\n                CustomValidator\n                    .root[Term](validTerms.add, c =\u003e s\"${c.path}: mistake '${c.value}' is duplicate\")\n                    .toParser\n                    .liftToTraverseCombiningP[List]\n                    .comapP[HalfParsedTermModel](_.mistakes)\n                    //update model with unique mistakes\n                    .map(a =\u003e m.copy(mistakes = a))\n            }\n            .liftToTraverseCombiningP[List]\n    }\n\n//parsed model should have as minimum one mistake\ndef nelParser[A] = CustomParser\n    .root[List[A], NonEmptyList[A]](_.toNel, c =\u003e s\"${c.path}: cannot be empty\")\nval halfToFullModelParser = CustomParser\n    .context[HalfParsedTermModel, TermModel](m =\u003e\n        nelParser[Term]\n            .comapP[HalfParsedTermModel](_.mistakes)\n            .map(mistakes =\u003e TermModel(m.term, mistakes))\n    )\n\n//combine all parsers together\nval modelsParser = (\n    termParser\n        .andThen(repositoryTermParser)\n        .comapP[RawTermModel](_.term),\n    termParser\n        .andThen(repositoryTermParser)\n        .liftToTraverseCombiningP[List]\n        .comapP[RawTermModel](_.mistakes),\n)\n    .parMapN(HalfParsedTermModel.apply)\n    .liftToTraverseCombiningP[List]\n    .andThen(uniqueTermsParser)\n    .andThen(uniqueTermsMistakesParser)\n    .andThen(halfToFullModelParser.liftToTraverseCombiningP[List])\n```\n\n(full list of test cases can be found [here](https://github.com/yakivy/dupin/blob/master/core/test/src/dupin/readme/ComplexExampleFixture.scala))\n\n### Roadmap\n- add unzip from index for validator/parser\n- enrich parser tests with validator cases\n- optimize `Parser.liftToTraverseCombiningP`, `combineK` is often slow, for example for lists\n- add complex example without parser for comparison\n- rename comap to contramap\n\n### Changelog\n\n#### 0.6.x\n- add Parser type\n- replace implicit conversion (`comapToP`, ...) with explicit lift methods (`liftToTraverseP`, ...)\n- a couple of minor fixes\n\n#### 0.5.x\n- simplify internal validator function\n- expose validator contravariant monoidal instance `ContravariantMonoidal[Validator[F, E, *]]`\n\n#### 0.4.x\n- add Scala 3 support for Scala Native\n- update Scala JS version\n- optimize path concatenation\n- separate F Validator methods (like `rootF`)\n- add Validator methods with context (like `combineC`)\n\n#### 0.3.x:\n- rename `dupin.Validator.compose` to `dupin.Validator.comap`, similar to `cats.Contravariant.contramap`\n- rename `dupin.Validator.combinePK` to `dupin.Validator.combinePL`, where `L` stands for \"lifted\" to reflect method signature\n- optimize a naive implementation of `ValidatorComapToP.validatorComapToPForTraverse` that threw StackOverflowException for long lists\n- minor refactorings\n\n#### 0.2.x:\n- migrate to mill build tool\n- add Scala 3, Scala JS and Scala Native support\n- expose validator monoid instance `MonoidK[Validator[F, E, *]]`\n- rename `dupin.base` package to `dupin.basic`\n- various refactorings and cleanups","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fyakivy%2Fdupin","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fyakivy%2Fdupin","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fyakivy%2Fdupin/lists"}