Pourquoi ArkType ?
Chaque validateur runtime vous demande d'apprendre une seconde langue. Avec Zod vous écrivez z.object({ name: z.string() }). Avec Valibot, v.object({ name: v.string() }). Les deux fonctionnent — mais aucun ne ressemble au TypeScript que vous écrivez tous les jours.
ArkType fait le pari inverse. Vous écrivez le type :
const User = type({ name: "string" })Cette chaîne "string" n'est pas un mot-clé magique. C'est une expression de type TypeScript, analysée et vérifiée par le compilateur TypeScript lui-même au moment de l'édition. Tapez une coquille comme "strng" et votre éditeur la souligne avant même que vous exécutiez quoi que ce soit. Écrivez "string | number" et vous obtenez une union discriminée à l'exécution et une inférence statique string | number — à partir des mêmes six caractères.
L'objectif de conception est l'isomorphisme : une seule définition, correcte dans les deux mondes. Le bénéfice : vos schémas cessent d'être un univers parallèle qu'il faut maintenir en phase avec vos types.
Ce tutoriel construit une application Next.js 15 petite mais complète avec ArkType 2.2 — la version qui a introduit les fonctions validées, les groupes de capture regex typés, les opérateurs n-aires et l'interopérabilité Standard Schema.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ installé
- TypeScript 5.1 ou plus récent (5.9+ recommandé ; ArkType s'appuie fortement sur l'inférence moderne)
- Une bonne connaissance de l'App Router de Next.js et des Server Actions
- Un éditeur avec un serveur de langage TypeScript fonctionnel (VS Code, WebStorm, Zed)
- Des notions de validation runtime — si vous avez déjà utilisé Zod ou Valibot, vous êtes largement prêt
Ce que vous allez construire
Une fonctionnalité de « soumission de conférence » validée à chaque frontière :
- Un schéma typé pour les soumissions, écrit en syntaxe TypeScript
- Un pipeline de morphs qui transforme les chaînes brutes de
FormDataen vrais nombres, dates et slugs - L'extraction typée de données structurées depuis un code de session, via regex
- Un scope réutilisable partagé entre le client et le serveur
- Une Server Action validée qui renvoie des erreurs au niveau du champ
- Des variables d'environnement vérifiées au runtime, qui échouent au démarrage et non en production
- Un export JSON Schema pour la documentation de votre API publique
Tout est typé de bout en bout. Aucune interface écrite à la main, aucune forme dupliquée.
Étape 1 : Mise en place du projet
Créez un nouveau projet Next.js et installez ArkType :
npx create-next-app@latest talk-portal --typescript --app --tailwind --eslint
cd talk-portal
npm install arktypeArkType est une dépendance unique, sans aucun paquet runtime propre. Ajoutez dès maintenant le pont JSON Schema optionnel — vous l'utiliserez à l'étape 12 :
npm install @ark/json-schemaOuvrez tsconfig.json et vérifiez que le mode strict est activé. L'inférence d'ArkType en dépend :
{
"compilerOptions": {
"strict": true,
"skipLibCheck": true,
"moduleResolution": "bundler"
}
}Si strict est désactivé, ArkType valide toujours correctement à l'exécution mais son inférence statique se dégrade fortement — les clés optionnelles et les sorties de morphs s'élargissent en any à plusieurs endroits. Activez-le avant d'écrire le moindre schéma.
Étape 2 : Votre premier type
Créez lib/schemas/talk.ts :
import { type } from "arktype"
export const Talk = type({
title: "string",
track: "'ai' | 'web' | 'devops' | 'design'",
"coAuthor?": "string"
})Trois choses se sont produites en six lignes :
"string"a été analysé en un validateur de chaîne à l'exécution."'ai' | 'web' | 'devops' | 'design'"a été analysé en union discriminée — ArkType compile cela en un switch, pas en une cascade de comparaisons."coAuthor?"a rendu la clé optionnelle. Le?se place sur la clé, pas sur la valeur, exactement comme dans une interface TypeScript.
Comparez avec l'interface équivalente que vous auriez écrite de toute façon :
interface Talk {
title: string
track: "ai" | "web" | "devops" | "design"
coAuthor?: string
}Les formes sont quasi identiques. C'est précisément l'objectif.
Étape 3 : Valider et gérer les erreurs
Les types ArkType sont appelables. Appelez-en un avec des données inconnues et vous récupérez soit la valeur validée, soit un objet d'erreurs :
import { type } from "arktype"
import { Talk } from "@/lib/schemas/talk"
const out = Talk({
title: "Shipping AI Agents",
track: "quantum"
})
if (out instanceof type.errors) {
console.error(out.summary)
// track must be "ai", "web", "devops" or "design" (was "quantum")
} else {
console.log(out.title) // entièrement typé en string
}Pas de séparation entre .parse() et .safeParse(). Un seul point d'appel, une seule bifurcation. Le test instanceof type.errors est un garde de type TypeScript : dans la branche else, out se restreint automatiquement au type validé.
Quand vous voulez le comportement qui lève une exception — chargement de configuration, tests, appels internes de confiance — utilisez .assert() :
const talk = Talk.assert({ title: "Shipping AI Agents", track: "ai" })
// lève une TraversalError si invalide, renvoie la valeur typée sinonPour des erreurs d'interface au niveau du champ, parcourez le tableau d'erreurs plutôt que de lire le résumé :
if (out instanceof type.errors) {
for (const error of out) {
console.log(error.path, error.message)
// ["track"] must be "ai", "web", "devops" or "design" (was "quantum")
}
}out.summary est une chaîne multiligne lisible par un humain, idéale pour les logs et la sortie CLI. La forme itérable avec error.path est celle qu'il vous faut pour les formulaires, car elle indique quel champ mettre en évidence.
Étape 4 : Inférer les types TypeScript
N'écrivez jamais l'interface à la main. Dérivez-la :
export type Talk = typeof Talk.inferVous avez désormais une valeur nommée Talk et un type nommé Talk dans le même module — TypeScript sépare les espaces de noms des valeurs et des types, donc c'est légal et idiomatique dans une base de code ArkType.
Quand un schéma contient des morphs (étape 6), les types d'entrée et de sortie divergent. ArkType expose les deux :
type TalkInput = typeof Talk.inferIn // ce que vous passez
type TalkOutput = typeof Talk.infer // ce que vous récupérezUtilisez inferIn pour l'état des formulaires et les types de requêtes API ; utilisez infer pour tout ce qui vient après la validation.
Étape 5 : Contraintes et mots-clés intégrés
Un "string" brut suffit rarement. ArkType fournit une bibliothèque de mots-clés et une syntaxe de contraintes, toutes deux à l'intérieur de la chaîne de définition.
Étoffez lib/schemas/talk.ts :
import { type } from "arktype"
export const Talk = type({
// les contraintes de longueur se lisent comme des comparaisons
title: "5 <= string <= 120",
abstract: "string >= 200",
// mots-clés pointés pour les formats courants
email: "string.email",
slidesUrl: "string.url",
submissionId: "string.uuid",
// contraintes numériques et divisibilité
durationMinutes: "number.integer >= 15",
seatBlock: "number % 5",
track: "'ai' | 'web' | 'devops' | 'design'",
// les tableaux sont un suffixe, exactement comme en TypeScript
tags: "string[]",
// et ils acceptent leurs propres bornes de longueur
speakers: "string.email[] >= 1"
})Petit tour d'horizon :
| Syntaxe | Signification |
|---|---|
5 <= string <= 120 | Longueur de chaîne entre 5 et 120 inclus |
string >= 200 | Longueur minimale de 200 |
number.integer >= 15 | Entier, au moins 15 |
number % 5 | Divisible par 5 |
string[] | Tableau de chaînes |
string.email[] >= 1 | Tableau non vide de chaînes e-mail |
ArkType 2.2 a également ajouté string.hex et string.regex au jeu de mots-clés, aux côtés des existants comme string.date, string.json, string.semver, string.ip et number.epoch.
Les intersections utilisent &, ce qui permet de combiner un mot-clé et un motif :
const CompanyEmail = type("string.email & /@noqta\\.tn$/")À l'intérieur d'un littéral de chaîne TypeScript, chaque antislash d'une regex doit être doublé. /\d{2}/ devient "/\\d{2}/". Oubliez-le et vous obtiendrez une erreur d'analyse déroutante pointant vers le mauvais caractère.
Étape 6 : Les morphs — transformer, pas seulement valider
Les entrées web arrivent sous forme de chaînes. Un validateur qui se contente de dire « ceci est une chaîne numérique valide » vous laisse appeler Number() à la main ensuite, hors du système de types. Les morphs comblent ce vide.
Un morph est une fonction appliquée après validation, et son type de retour se propage dans le type de sortie inféré. Créez lib/schemas/form.ts :
import { type } from "arktype"
// forme tuple : [entrée, "=>", transformation]
const TrimmedString = type("string", "=>", (s) => s.trim())
// l'opérateur pipe enchaîne valider -> transformer -> valider
const PositiveIntFromString = type("string.integer.parse |> number > 0")
const Slug = type("string", "=>", (s) =>
s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
)
export const TalkFormInput = type({
title: TrimmedString,
durationMinutes: PositiveIntFromString,
slug: Slug
})Exécutez-le :
const out = TalkFormInput({
title: " Shipping AI Agents ",
durationMinutes: "45",
slug: "Shipping AI Agents!"
})
// out.title -> "Shipping AI Agents" (string)
// out.durationMinutes -> 45 (number, pas string)
// out.slug -> "shipping-ai-agents" (string)Observez les types inférés. durationMinutes est typé number en sortie et string en entrée — typeof TalkFormInput.inferIn et typeof TalkFormInput.infer diffèrent, et les deux sont corrects.
L'opérateur pipe |> est devenu intégrable dans les chaînes de définition avec la 2.2, et type.pipe() offre la forme n-aire quand la chaîne s'allonge :
const TrimToNonEmpty = type.pipe(
type.string,
(s) => s.trimStart(),
type.string.atLeastLength(1)
)Un morph peut aussi échouer. Renvoyez ctx.error(...) pour rejeter depuis la transformation :
const IsoDate = type("string", "=>", (s, ctx) => {
const date = new Date(s)
return Number.isNaN(date.valueOf())
? ctx.error("a valid ISO 8601 date")
: date
})IsoDate s'infère maintenant en Date et rejette les entrées invalides avec une vraie erreur ArkType plutôt qu'une exception.
Les morphs sont la raison pour laquelle « parser plutôt que valider » est facile avec ArkType. Poussez chaque conversion chaîne-vers-objet-métier dans le schéma, et le reste de votre code ne verra jamais une valeur non transformée.
Étape 7 : Regex typées avec groupes de capture
C'est la fonctionnalité phare d'ArkType 2.2, et elle n'a pas d'équivalent chez les autres validateurs majeurs.
Préfixez un littéral regex par x/ et ArkType analyse le motif au niveau des types, vous donnant des groupes de capture nommés et typés sur la sortie validée :
import { type } from "arktype"
export const SessionCode = type({
code: "x/^(?<year>\\d{4})-(?<track>[A-Z]{3})-(?<seq>\\d{3})$/"
})
const data = SessionCode.assert({ code: "2026-WEB-014" })
data.code.groups.year // "2026" — typé string, avec autocomplétion
data.code.groups.track // "WEB"
data.code.groups.seq // "014"Survolez groups dans votre éditeur : TypeScript connaît les noms exacts des clés, parce que le compilateur a analysé le motif. Renommez un groupe de capture dans la regex et tous ses consommateurs cassent à la compilation. Écrivez data.code.groups.yaer par erreur et vous obtenez une erreur, pas un undefined à trois heures du matin.
La mécanique sous-jacente s'appelle ArkRegex, un remplacement direct de RegExp avec inférence de types complète et sans surcoût à l'exécution — l'analyse se déroule entièrement dans le système de types.
Combinez-la avec un morph pour extraire des données structurées en une seule passe :
export const ParsedSessionCode = type(
"x/^(?<year>\\d{4})-(?<track>[A-Z]{3})-(?<seq>\\d{3})$/",
"=>",
(code) => ({
year: Number(code.groups.year),
track: code.groups.track,
sequence: Number(code.groups.seq)
})
)Une seule définition valide désormais le format, en extrait les parties et renvoie un objet typé.
Étape 8 : Valeurs par défaut, narrows et brands
Les valeurs par défaut utilisent = dans la définition. Une clé avec valeur par défaut est optionnelle en entrée et obligatoire en sortie :
const TalkSettings = type({
isRecorded: "boolean = true",
maxAttendees: "number.integer = 100",
track: "'ai' | 'web' = 'web'"
})
TalkSettings({}) // { isRecorded: true, maxAttendees: 100, track: "web" }Pour des valeurs par défaut non primitives, passez une fabrique afin que les instances ne soient pas partagées :
const Draft = type({
tags: type("string[]").default(() => [])
})Les narrows attachent des prédicats personnalisés exécutés après la validation structurelle. Utilisez la forme tuple avec : :
const EventWindow = type({
startsAt: "string.date.parse",
endsAt: "string.date.parse"
}).narrow((window, ctx) =>
window.endsAt > window.startsAt ||
ctx.mustBe("an end date after the start date")
)Le narrow reçoit l'objet entièrement transformé : vous comparez donc de vraies valeurs Date, pas des chaînes.
Les brands rendent un type nominal sans changer le comportement à l'exécution, via # :
const SeatCount = type("number % 5#seatBlock")
type SeatCount = typeof SeatCount.infer
// un simple `number` ne sera plus assignable à SeatCountUne fonction attendant SeatCount ne peut désormais plus accepter silencieusement n'importe quel nombre qui traînait — il doit sortir du validateur.
Étape 9 : Les scopes pour des modules de schémas réutilisables
Dès que vous dépassez une poignée de types qui se référencent mutuellement, les alias textuels battent les imports. Un scope est un espace de noms de définitions pouvant se référencer entre elles par leur nom :
import { scope } from "arktype"
export const conference = scope({
// alias : définitions brutes, PAS enveloppées dans type()
TalkId: "string.uuid",
Track: "'ai' | 'web' | 'devops' | 'design'",
Speaker: {
email: "string.email",
name: "5 <= string <= 80"
},
Talk: {
id: "TalkId",
title: "5 <= string <= 120",
track: "Track",
speakers: "Speaker[] >= 1",
"relatedTalks?": "TalkId[]"
}
})
export const schemas = conference.export()Utilisez le module exporté partout :
import { schemas } from "@/lib/schemas/conference"
const out = schemas.Talk(payload)
type Talk = typeof schemas.Talk.inferL'erreur ArkType la plus fréquente : envelopper les alias d'un scope dans type(). À l'intérieur de scope({...}), écrivez Track: "'ai' | 'web'", jamais Track: type("'ai' | 'web'"). La version enveloppée compile, mais casse la résolution d'alias pour tout ce qui la référence.
Les scopes gèrent aussi la récursion via this et les signatures d'index :
export const treeScope = scope({
Category: {
name: "string",
"children?": "Category[]"
},
TalksById: {
"[string.uuid]": "Talk | undefined"
}
})Étape 10 : Fonctions validées avec type.fn
ArkType 2.2 a introduit type.fn, qui enveloppe une fonction pour que ses paramètres et sa valeur de retour soient validés à l'exécution :
import { type } from "arktype"
export const scoreTalk = type.fn(
"string >= 10", // résumé
"number.integer >= 1", // nombre de relecteurs
":", // séparateur avant le type de retour
"0 <= number <= 100"
)((abstract, reviewers) => {
const raw = Math.min(abstract.length / 10, 100)
return Math.round(raw / reviewers) * reviewers
})
scoreTalk("A long enough abstract about agents", 3) // number
scoreTalk("short", 3) // TraversalError: must be at least length 10Les paramètres acceptent valeurs par défaut, optionnels et variadiques avec la même syntaxe que dans les schémas d'objets :
const notify = type.fn(
"string.email",
"string = 'Your talk was received'"
)((to, message) => `${to}: ${message}`)
notify("speaker@noqta.tn") // "speaker@noqta.tn: Your talk was received"C'est réellement utile aux frontières de confiance : gestionnaires de webhooks, points d'entrée de plugins, et tout ce qu'un appel d'outil de LLM peut atteindre.
Étape 11 : Une Server Action Next.js validée
Assemblons le tout. Créez app/submit/actions.ts :
"use server"
import { type } from "arktype"
const Submission = type({
title: "5 <= string <= 120",
abstract: "string >= 200",
email: "string.email",
track: "'ai' | 'web' | 'devops' | 'design'",
durationMinutes: "string.integer.parse |> 15 <= number <= 90",
"coAuthor?": "string.email"
})
export type SubmissionState = {
ok: boolean
message?: string
fieldErrors?: Record<string, string>
}
export async function submitTalk(
_prev: SubmissionState,
formData: FormData
): Promise<SubmissionState> {
const result = Submission(Object.fromEntries(formData))
if (result instanceof type.errors) {
const fieldErrors: Record<string, string> = {}
for (const error of result) {
const key = String(error.path[0] ?? "form")
fieldErrors[key] ??= error.message
}
return { ok: false, message: "Please fix the highlighted fields.", fieldErrors }
}
// result.durationMinutes est ici un nombre, déjà borné
await saveTalk(result)
return { ok: true, message: "Submission received." }
}Object.fromEntries(formData) vous donne un objet de chaînes. Le morph string.integer.parse |> 15 <= number <= 90 convertit et borne durationMinutes dans le schéma, si bien que saveTalk reçoit un vrai nombre garanti dans l'intervalle.
Le composant client le consomme avec useActionState :
"use client"
import { useActionState } from "react"
import { submitTalk, type SubmissionState } from "./actions"
const initial: SubmissionState = { ok: false }
export function TalkForm() {
const [state, action, pending] = useActionState(submitTalk, initial)
return (
<form action={action} className="space-y-4">
<label className="block">
<span>Title</span>
<input name="title" className="input" />
{state.fieldErrors?.title && (
<p className="text-red-500 text-sm">{state.fieldErrors.title}</p>
)}
</label>
<label className="block">
<span>Duration (minutes)</span>
<input name="durationMinutes" type="number" className="input" />
{state.fieldErrors?.durationMinutes && (
<p className="text-red-500 text-sm">
{state.fieldErrors.durationMinutes}
</p>
)}
</label>
<button disabled={pending}>
{pending ? "Submitting…" : "Submit talk"}
</button>
{state.message && <p>{state.message}</p>}
</form>
)
}Validation côté client via Standard Schema
ArkType implémente Standard Schema, l'interface partagée adoptée par l'écosystème de validation. React Hook Form accepte donc un type ArkType directement via le resolver standard — sans adaptateur spécifique à ArkType :
import { useForm } from "react-hook-form"
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"
import { Submission } from "@/lib/schemas/submission"
const form = useForm({
resolver: standardSchemaResolver(Submission)
})Standard Schema fonctionne aussi dans l'autre sens : ArkType 2.2 peut intégrer des validateurs issus de n'importe quelle bibliothèque conforme dans ses propres définitions. Migrer une grosse base de code un schéma à la fois devient donc un non-événement :
import { type } from "arktype"
import { ZodAddress } from "./legacy/address" // toujours un schéma Zod
const Attendee = type({
name: "string",
address: ZodAddress // fonctionne tel quel
})Étape 12 : Variables d'environnement et JSON Schema
Validez les variables d'environnement une fois, au chargement du module, pour qu'une clé manquante fasse planter l'application au démarrage plutôt que sur la requête d'un client. Créez lib/env.ts :
import { type } from "arktype"
const Env = type({
NODE_ENV: "'development' | 'test' | 'production'",
DATABASE_URL: "string.url",
RESEND_API_KEY: "string >= 20",
"SENTRY_DSN?": "string.url",
MAX_UPLOAD_MB: "string.integer.parse |> 1 <= number <= 50"
})
export const env = Env.assert(process.env)Importez env au lieu de toucher à process.env ailleurs. MAX_UPLOAD_MB est déjà un nombre validé quand le premier morceau de code le lit.
Pour une API publique, générez un JSON Schema à partir de la même définition avec @ark/json-schema :
import { Submission } from "@/lib/schemas/submission"
export function GET() {
return Response.json(Submission.toJsonSchema())
}La conversion est bidirectionnelle — vous pouvez aussi analyser un JSON Schema existant vers un type ArkType, avec des replis configurables pour les constructions que JSON Schema exprime et qu'ArkType n'exprime pas. Cela rend l'adoption d'ArkType praticable derrière un contrat OpenAPI que vous ne contrôlez pas.
Tester votre implémentation
Ajoutez Vitest et testez les deux branches :
npm install -D vitest// lib/schemas/talk.test.ts
import { describe, it, expect } from "vitest"
import { type } from "arktype"
import { Submission } from "./submission"
const valid = {
title: "Shipping AI Agents in Production",
abstract: "x".repeat(250),
email: "speaker@noqta.tn",
track: "ai",
durationMinutes: "45"
}
describe("Submission", () => {
it("parses duration into a number", () => {
const out = Submission(valid)
expect(out).not.toBeInstanceOf(type.errors)
if (!(out instanceof type.errors)) {
expect(out.durationMinutes).toBe(45)
expect(typeof out.durationMinutes).toBe("number")
}
})
it("rejects an unknown track with a field path", () => {
const out = Submission({ ...valid, track: "quantum" })
expect(out).toBeInstanceOf(type.errors)
if (out instanceof type.errors) {
expect([...out][0].path).toEqual(["track"])
}
})
it("enforces the duration ceiling", () => {
const out = Submission({ ...valid, durationMinutes: "500" })
expect(out).toBeInstanceOf(type.errors)
})
})Lancez npx vitest run. Trois tests verts confirment le morph, l'union et la borne supérieure.
Une bonne habitude : ajoutez une assertion au niveau des types pour que toute dérive du schéma casse le build.
import type { Equals } from "arktype/internal/utils"
type Out = typeof Submission.infer
const _check: Equals<Out["durationMinutes"], number> = trueDépannage
« Type instantiation is excessively deep » — généralement une union très large ou un scope profondément récursif. Découpez le schéma en un scope avec des alias nommés ; ArkType met en cache la résolution des alias et la pression sur la profondeur retombe.
L'autocomplétion rame dans un gros fichier de schémas — passez à TypeScript 5.9+ et assurez-vous que skipLibCheck vaut true. L'inférence d'ArkType est lourde par conception, et les compilateurs plus anciens le paient.
La regex ne correspond jamais — vous avez presque certainement mis un simple antislash dans un littéral de chaîne. "/\d+/" est faux ; "/\\d+/" est juste.
groups vaut undefined sur une correspondance regex — vous avez utilisé un littéral /.../ classique au lieu du préfixe x/.../. Seule la forme x/ participe à l'analyse au niveau des types.
Un alias de scope se résout en unknown — l'alias était enveloppé dans type(). Retirez l'enveloppe.
La valeur par défaut est partagée entre objets — vous avez passé un tableau ou un objet littéral comme défaut. Passez plutôt une fabrique : .default(() => []).
Une Server Action rejette toujours un champ numérique — rappelez-vous que les valeurs de FormData sont des chaînes. Utilisez string.integer.parse ou string.numeric.parse plutôt que number.
Pour aller plus loin
- Comparez les approches avec notre guide de validation de schémas Zod v4 et le tutoriel de validation modulaire Valibot — les trois implémentent Standard Schema, donc ils interopèrent.
- Injectez la sortie JSON Schema d'ArkType dans des sorties LLM structurées ; voyez BAML pour des sorties LLM structurées et typées.
- Associez les scopes à des routes API typées avec oRPC pour des contrats validés aux deux bouts du fil.
- Enveloppez vos gestionnaires d'outils LLM dans
type.fnafin qu'un argument halluciné soit rejeté avant d'atteindre votre base de données.
Conclusion
La promesse d'ArkType est étroite et précise : votre validateur devrait ressembler à vos types, parce qu'il est vos types. En pratique, cela se traduit par quatre gains concrets.
Vous écrivez moins. "5 <= string <= 120" remplace une chaîne de builder, et la définition se lit comme vous décririez la contrainte à voix haute.
Vous détectez plus tôt. Les chaînes de définition sont vérifiées par le compilateur TypeScript : une coquille dans un schéma devient un soulignement rouge, pas une surprise à l'exécution. Les groupes de capture regex typés étendent cette garantie sur un terrain qu'aucun autre validateur n'atteint.
Vous parsez au lieu de valider. Les morphs convertissent chaînes de FormData, dates ISO et variables d'environnement numériques en vraies valeurs métier dès la frontière, si bien que plus rien en aval ne manipule une forme non transformée.
Et vous n'êtes pas enfermé. Standard Schema signifie qu'ArkType se compose avec les schémas Zod et Valibot dans les deux sens, et @ark/json-schema fait le pont vers le monde OpenAPI — l'adoption peut donc être incrémentale, schéma par schéma, en commençant par ce formulaire qui vous agace le plus aujourd'hui.