{"slug": "one-model-many-views-type-safe-transforms-with-valibot-and-doba", "title": "One Model, Many Views: Type-Safe Transforms with Valibot and Doba", "summary": "Karol Broda released doba, a schema registry for type-safe data transforms that works with any Standard Schema-compatible library and pairs with Valibot, enabling developers to define typed migrations between schema variants such as database, frontend, and AI models. The tool automatically routes transforms through intermediate schemas when no direct migration exists, and Valibot's type inference ensures migrations fail to compile if schemas change.", "body_md": "*This is a guest post by Karol Broda. I like building tools that solve problems I keep running into, and doba came out of one of those.*\n\nMost apps have the same data in multiple shapes. A database row has a password hash and internal metadata. The frontend gets a sanitized version without any of that. The AI endpoint needs a flat struct with just the fields the model cares about. A legacy API from two years ago returns something different entirely.\n\nThe typical solution is a handful of functions. `toFrontendUser()`\n\n, `toAIUser()`\n\n, `fromLegacyV1()`\n\n. Each one is fine on its own. The problem is that they don't know about each other.\nSomeone needs legacy-to-AI, there's no function for that, so they chain two together and hope the intermediate shape doesn't change. Nobody writes tests for these because they're \"just mapping.\"\nThen a schema change ships and half your transforms silently produce wrong data.\n\nI wrote [doba](https://doba.karolbroda.com) to deal with this ([source](https://github.com/karol-broda/doba)). It's a schema registry that works with any [Standard Schema](https://github.com/standard-schema/standard-schema) compatible library, but I use it with Valibot, and the two pair well for a few reasons.\n\nValibot's modular architecture means your bundle only includes what you actually use. A registry with ten schema variants doesn't pull in validators that only two of them use. With most other schema libraries, you'd import the whole thing regardless.\n\nValibot's type inference is also what makes doba's typed migrations work. When you define a schema with `v.object()`\n\n, the inferred type flows straight into the migration function signature. Rename a field in the Valibot schema and the migration won't compile until you fix it. That feedback loop is the core of what makes this useful.\n\nSchemas and migrations\n\nYou register your Valibot schemas and define migrations between them. Each migration function is fully typed against the source and target schemas.\n\n``` js\nimport { createRegistry } from 'dobajs';\nimport * as v from 'valibot';\n\nconst databaseUser = v.object({\n  id: v.string(),\n  email: v.pipe(v.string(), v.email()),\n  passwordHash: v.string(),\n  createdAt: v.pipe(v.string(), v.isoTimestamp()),\n  settings: v.object({\n    theme: v.picklist(['light', 'dark']),\n    notifications: v.object({\n      email: v.boolean(),\n      push: v.boolean(),\n    }),\n  }),\n});\n\nconst frontendUser = v.object({\n  id: v.string(),\n  email: v.pipe(v.string(), v.email()),\n  createdAt: v.pipe(v.string(), v.isoTimestamp()),\n  settings: v.object({\n    theme: v.picklist(['light', 'dark']),\n    notifications: v.object({\n      email: v.boolean(),\n      push: v.boolean(),\n    }),\n  }),\n});\n\nconst aiUser = v.object({\n  id: v.string(),\n  email: v.string(),\n  theme: v.string(),\n  hasNotifications: v.boolean(),\n});\n\nconst registry = createRegistry({\n  schemas: { database: databaseUser, frontend: frontendUser, ai: aiUser },\n  migrations: {\n    'database->frontend': (user) => ({\n      id: user.id,\n      email: user.email,\n      createdAt: user.createdAt,\n      settings: user.settings,\n    }),\n    'frontend->ai': (user) => ({\n      id: user.id,\n      email: user.email,\n      theme: user.settings.theme,\n      hasNotifications:\n        user.settings.notifications.email || user.settings.notifications.push,\n    }),\n  },\n});\n```\n\nAdd a new required field to a target schema and every migration pointing at it lights up red until you handle it. That's Valibot's type inference doing the work.\n\nYou don't need to write a migration for every possible pair of schemas either. If there's no direct path, doba walks the graph and chains through intermediate ones automatically:\n\n``` php\n// We only defined database->frontend and frontend->ai,\n// but this still works. doba routes through frontend.\nconst result = await registry.transform(databaseData, 'database', 'ai');\n```\n\nMigration context\n\nLegacy migrations are full of quiet decisions. Defaulting an ID because the old format didn't have one. Guessing an email from a name field. Mapping a boolean called `darkMode`\n\nto a theme enum.\nIn a regular transform function, all of that disappears into the function body. You migrate 10k legacy users, three months later someone asks why half of them have `unknown@example.com`\n\nas their email,\nand nobody remembers what the migration assumed.\n\ndoba passes a context object to every migration so you can record what you defaulted and why.\n\n``` js\nconst legacyUser = v.object({\n  name: v.optional(v.string()),\n  darkMode: v.optional(v.boolean()),\n});\n\n// A separate registry that also includes the legacy schema\nconst extendedRegistry = createRegistry({\n  schemas: {\n    database: databaseUser,\n    frontend: frontendUser,\n    ai: aiUser,\n    legacy: legacyUser,\n  },\n  migrations: {\n    // ...previous migrations\n    'legacy->frontend': (user, ctx) => {\n      ctx.defaulted(['id'], 'generated new id');\n      ctx.defaulted(['createdAt'], 'set to current timestamp');\n\n      let email = 'unknown@example.com';\n      if (user.name && user.name.length > 0) {\n        email = `${user.name.toLowerCase().replace(/\\s+/g, '.')}@legacy.example.com`;\n        ctx.warn(`converted name \"${user.name}\" to email`);\n      }\n\n      return {\n        id: `legacy-${Date.now()}`,\n        email,\n        createdAt: new Date().toISOString(),\n        settings: {\n          theme: user.darkMode === true ? 'dark' : 'light',\n          notifications: { email: false, push: false },\n        },\n      };\n    },\n  },\n});\n\nconst result = await extendedRegistry.transform(\n  { name: 'Alice Johnson', darkMode: true },\n  'legacy',\n  'frontend'\n);\n\nif (result.ok) {\n  result.meta.defaults; // [{ path: ['id'], message: 'generated new id', ... }]\n  result.meta.warnings; // [{ message: 'converted name \"Alice Johnson\" to email', ... }]\n}\n```\n\n`result`\n\nis a discriminated union. `ok: true`\n\nwith value and metadata, `ok: false`\n\nwith typed validation errors. No try/catch.\n\nMost migrations are mechanical. Renaming a field, dropping another, adding a default. doba has a `pipe`\n\nbuilder for that so you don't have to write the boilerplate by hand:\n\n``` php\n'database->frontend': {\n  pipe: (p) => p.drop('passwordHash'),\n},\n```\n\nThe builder tracks the shape as you chain. A `.rename('foo', 'bar')`\n\nfollowed by `.drop('foo')`\n\nis a type error.\n\nIdentifying unknown data\n\nSometimes you get data and don't know which schema it came from. doba can figure that out and transform it:\n\n``` js\nimport { createRegistry, match } from 'dobajs';\n\nconst registry = createRegistry({\n  schemas: { database: databaseUser, frontend: frontendUser, ai: aiUser },\n  migrations: {\n    // ...same as before\n  },\n  identify: {\n    database: match.field('passwordHash'),\n    frontend: match.fields('createdAt', 'settings'),\n    ai: match.field('hasNotifications'),\n  },\n});\n\nconst result = await registry.identifyAndTransform(unknownData, 'ai');\n\nif (result.ok) {\n  result.value; // transformed data\n  result.meta.from; // which schema it detected\n  result.meta.path; // the route it took, e.g. ['database', 'ai']\n}\n```\n\nGuards run in definition order. If none match, you get a typed error, not a runtime crash.\n\nWhere this helps\n\nThe examples above are simplified, but the pattern shows up in a lot of places.\n\nAPI versioning is the obvious one. You ship v1, then v2 changes the shape, then v3 splits a field into two. Clients are still sending all three versions. Instead of writing v1-to-v3 and v2-to-v3 converters by hand, you define v1-to-v2 and v2-to-v3 and the registry chains them. When v4 ships, you add one migration and everything upstream still works.\n\nLLM pipelines have a similar problem. Your database has a rich, nested user object, but the model prompt needs a flat struct with five fields. That transform is easy to write once. It's less easy to keep correct when the database schema evolves or when you need three different prompt formats for different models.\n\nLegacy imports are where the migration context really pays off. If you're pulling records from an old system and half the fields are missing or renamed, every decision you make during that conversion (\"defaulted email because the source didn't have one\") is recorded. When someone asks about it six months later, the metadata is right there on the result.\n\nEven something like a webhook handler fits. You receive payloads from a third party that's changed their format twice. You don't control when they migrate. `identifyAndTransform`\n\nfigures out which version came in and normalizes it.\n\nIf any of this sounds like a problem you have, take a look. Feedback and issues welcome.\n\nThanks to [Fabian Hiller](https://github.com/fabian-hiller) and the Valibot team for having me on the blog.", "url": "https://wpnews.pro/news/one-model-many-views-type-safe-transforms-with-valibot-and-doba", "canonical_source": "https://valibot.dev/blog/same-data-different-shapes/", "published_at": "2026-08-29 19:13:25+00:00", "updated_at": "2026-08-29 19:49:08.379743+00:00", "lang": "en", "topics": ["developer-tools", "ai-products"], "entities": ["Karol Broda", "doba", "Valibot", "Standard Schema"], "alternates": {"html": "https://wpnews.pro/news/one-model-many-views-type-safe-transforms-with-valibot-and-doba", "markdown": "https://wpnews.pro/news/one-model-many-views-type-safe-transforms-with-valibot-and-doba.md", "text": "https://wpnews.pro/news/one-model-many-views-type-safe-transforms-with-valibot-and-doba.txt", "jsonld": "https://wpnews.pro/news/one-model-many-views-type-safe-transforms-with-valibot-and-doba.jsonld"}}