Skip to content

Paraglide

@verific/paraglide accepts an explicit map of generated Paraglide 2 message functions. The map is typed, auditable and tree-shakeable; the adapter never guesses generated export names or reads a process-global locale.

Install and configure the application

bash
pnpm add @inlang/paraglide-js @verific/core @verific/paraglide vue zod

After generating your Paraglide messages, statically import each function used for validation:

ts
import type { MissingMessageMode } from '@verific/i18n'
import type { App } from 'vue'
import { createVerific } from '@verific/core'
import { paraglideMessages } from '@verific/paraglide'
import { ref } from 'vue'
import { errors_invalid_email } from './paraglide/messages/errors_invalid_email.js'

export const messageLocale = ref<'en' | 'es'>('en')

export function createValidationMessages(missing?: MissingMessageMode) {
  return paraglideMessages({
    'errors.invalidEmail': errors_invalid_email,
  }, {
    fallbackPrefix: 'errors',
    locale: () => messageLocale.value,
    missing,
  })
}

// #region strict-missing
export function createStrictValidationMessages() {
  return paraglideMessages({
    'errors.invalidEmail': errors_invalid_email,
  }, {
    fallbackPrefix: 'errors',
    locale: () => messageLocale.value,
    missing: 'throw',
  })
}
// #endregion strict-missing

export function installValidation(app: App) {
  app.use(createVerific({ messages: createValidationMessages() }))
}

Call installValidation(app) before mounting. The imported message module is actual Paraglide-generated output, and this displayed source is compiled by the documentation test suite against the exported adapter.

Every catalogue key is visibly paired with one generated function. Functions keep their concrete input types; no wrapper or cast is required. Verific passes semantic values as inputs, includes count only when present and supplies the selected locale through Paraglide's options argument.

Use it in a form

vue
<script setup lang="ts">
import { useValidation } from '@verific/core'
import { reactive } from 'vue'
import { z } from 'zod'
import { messageLocale } from './paraglide-setup'

const form = reactive({ email: '' })
const schema = z.object({ email: z.email() })
const { errorsFor, hasError, validate, validateFor } = useValidation(schema, form, {
  messagePrefix: 'forms.signup',
})

async function submit() {
  const result = await validate()
  if (result.success) {
    // Submit application-owned state.
  }
}
</script>

<template>
  <form novalidate @submit.prevent="submit">
    <label for="email">Email</label>
    <input
      id="email"
      v-model="form.email"
      type="email"
      :aria-invalid="hasError('email')"
      aria-describedby="email-errors"
      @blur="validateFor('email')"
    >
    <div id="email-errors" aria-live="polite" aria-atomic="true">
      <p v-for="(error, index) in errorsFor('email')" :key="`${index}:${error}`">
        {{ error }}
      </p>
    </div>
    <button type="submit">
      Continue
    </button>
  </form>

  <button type="button" @click="messageLocale = messageLocale === 'en' ? 'es' : 'en'">
    Change message language
  </button>
</template>

The form imports the exact reactive locale exported by the setup above. This focused locale-switch flow is also compiled and exercised:

ts
import { messageLocale } from './paraglide-setup'

export function changeMessageLanguage() {
  messageLocale.value = messageLocale.value === 'en' ? 'es' : 'en'
}

Reading messageLocale.value in the required locale getter makes rendered errors reactive. Changing it updates existing error text without rerunning the schema.

Try it in the browser

  1. Select Validate with Paraglide once.
  2. Change Message language to Español.
  3. The committed message changes while Validation runs remains 1.
  4. Select Demonstrate missing-key fallback. The schema fallback and exact missing key and locale appear, still without another schema run. Select it again to restore the translated message.

Validation runs: 0

Validate once, then change the locale.

View the source used by this example
vue
<script setup lang="ts">
import type { CatalogueMissingMessageDiagnostic } from '@verific/i18n'
import { useValidation } from '@verific/core'
import { paraglideMessages } from '@verific/paraglide'
import { nextTick, ref } from 'vue'
import { z } from 'zod'
import { errors_invalid_email } from '../../guide/localisation/examples/paraglide/messages/errors_invalid_email.js'

const locale = ref<'en' | 'es'>('en')
const email = ref('not-an-email')
const runCount = ref(0)
const demonstrateMissing = ref(false)
const missingSequence = ref(0)
const missingDiagnostic = ref('')
const schema = z.object({
  email: z.email('Schema fallback: enter a valid email address'),
}).superRefine(() => {
  runCount.value += 1
})
const messages = paraglideMessages({
  'errors.invalidEmail': errors_invalid_email,
}, {
  fallbackPrefix: 'errors',
  key: ({ defaultKeys }) => demonstrateMissing.value
    ? [`demo.missing.${missingSequence.value}`]
    : defaultKeys,
  locale: () => locale.value,
  missing: reportMissing,
})
const outcome = ref('Validate once, then change the locale.')
const { errorsFor, hasError, isValidating, validate } = useValidation(
  schema,
  { email },
  { messages },
)

async function onSubmit() {
  const result = await validate()
  outcome.value = result.success
    ? 'The email address is valid.'
    : 'The committed error is translated when the locale changes.'

  if (!result.success) {
    await nextTick()
    document.getElementById('paraglide-email')?.focus()
  }
}

function reportMissing(diagnostic: CatalogueMissingMessageDiagnostic) {
  const attempt = diagnostic.attempts[0]
  const key = attempt?.keys[0] ?? 'unknown key'
  const attemptedLocale = attempt?.locale ?? 'unknown locale'
  missingDiagnostic.value = `Missing catalogue message. Add "${key}" for locale "${attemptedLocale}".`
}

function toggleMissingDemonstration() {
  missingDiagnostic.value = ''
  demonstrateMissing.value = !demonstrateMissing.value
  if (demonstrateMissing.value) {
    missingSequence.value += 1
  }
}
</script>

<template>
  <div class="verific-example">
    <form novalidate @submit.prevent="onSubmit">
      <div class="verific-example__toolbar">
        <div class="verific-example__field">
          <label for="paraglide-locale">Message language</label>
          <select id="paraglide-locale" v-model="locale" data-validation-skip>
            <option value="en">
              English
            </option>
            <option value="es">
              Español
            </option>
          </select>
        </div>
        <p class="verific-example__counter" aria-live="polite">
          Validation runs: <strong>{{ runCount }}</strong>
        </p>
      </div>

      <div class="verific-example__field">
        <label for="paraglide-email">Email address</label>
        <input
          id="paraglide-email"
          v-model="email"
          type="email"
          autocomplete="email"
          :aria-invalid="hasError('email')"
          aria-describedby="paraglide-email-errors"
        >
        <ul
          id="paraglide-email-errors"
          class="verific-example__errors"
          aria-live="polite"
          aria-atomic="true"
          :lang="locale"
        >
          <li v-for="(error, index) in errorsFor('email')" :key="`${index}:${error}`">
            {{ error }}
          </li>
        </ul>
      </div>

      <div class="verific-example__actions">
        <button type="submit" :disabled="isValidating">
          {{ isValidating ? 'Validating…' : 'Validate with Paraglide' }}
        </button>
        <button
          type="button"
          :aria-pressed="demonstrateMissing"
          aria-controls="paraglide-missing-diagnostic"
          @click="toggleMissingDemonstration"
        >
          Demonstrate missing-key fallback
        </button>
      </div>

      <p
        v-show="missingDiagnostic"
        id="paraglide-missing-diagnostic"
        class="verific-example__outcome"
        role="status"
        aria-live="polite"
      >
        {{ missingDiagnostic }}
      </p>

      <p class="verific-example__outcome" role="status" aria-live="polite">
        {{ outcome }}
      </p>
    </form>
  </div>
</template>

Missing keys and SSR

An explicit map catches base keys at compile time. Runtime form prefixes and issue paths can still miss, so use strict mode in exercised tests:

ts
export function createStrictValidationMessages() {
  return paraglideMessages({
    'errors.invalidEmail': errors_invalid_email,
  }, {
    fallbackPrefix: 'errors',
    locale: () => messageLocale.value,
    missing: 'throw',
  })
}

After validating in a test, read errorsFor('email') to exercise this strict adapter. The shown factory is compiled against generated output and its throw path is tested.

For SSR, create a request-owned locale ref or getter and create the adapter inside that application boundary. Do not call an ambient mutable locale selector. See the request-safe Nuxt plugin.

See the shared fallback contract and @verific/paraglide package README for the complete adapter surface.