Skip to content

Latest commit

 

History

History
114 lines (86 loc) · 3.55 KB

File metadata and controls

114 lines (86 loc) · 3.55 KB

tRPC

tRPC v11 accepts any Standard Schema V1 as procedure input. ata-validator implements Standard Schema natively, so a Validator instance drops straight into .input().

Install

npm install ata-validator @trpc/server

Procedure input

import { initTRPC } from '@trpc/server'
import { Validator } from 'ata-validator'

const t = initTRPC.create()

const userSchema = new Validator({
  type: 'object',
  properties: {
    id: { type: 'integer', minimum: 1 },
    name: { type: 'string', minLength: 1 },
    email: { type: 'string' },
  },
  required: ['id', 'name', 'email'],
})

export const appRouter = t.router({
  createUser: t.procedure
    .input(userSchema)
    .mutation(({ input }) => {
      // `input` is typed as unknown; add a build-time step to narrow.
      return { ok: true, id: (input as any).id }
    }),
})

Narrowing the input type

Validator itself is not parameterized, so input comes in as unknown by default. Two honest approaches for getting a typed input:

Option 1: compile the schema and use the generated type

npx ata compile schemas/user.json -o src/user.validator.mjs --name User
import { initTRPC } from '@trpc/server'
import { Validator } from 'ata-validator'
import type { User } from './schemas/user.validator.mjs'
import userSchemaJson from './schemas/user.json' with { type: 'json' }

const t = initTRPC.create()
const userSchema = new Validator(userSchemaJson)

export const appRouter = t.router({
  createUser: t.procedure
    .input(userSchema)
    .mutation(({ input }) => {
      const user = input as User  // runtime-validated, type-asserted
      return { ok: true, id: user.id }
    }),
})

The cast is safe because .input(userSchema) has already run the validator; if input is reachable, the shape matches User.

Option 2: skip the runtime validator, use the compiled one only

If the schema is static, the compiled module has both the type and a runtime check. You can bypass t.procedure.input() entirely:

import { initTRPC } from '@trpc/server'
import { TRPCError } from '@trpc/server'
import { isValid, type User } from './schemas/user.validator.mjs'

const t = initTRPC.create()

export const appRouter = t.router({
  createUser: t.procedure
    .mutation(({ rawInput }) => {
      if (!isValid(rawInput)) {
        throw new TRPCError({ code: 'BAD_REQUEST', message: 'invalid input' })
      }
      // `rawInput` is narrowed to User here by the type predicate.
      return { ok: true, id: rawInput.id }
    }),
})

Trade-off: loses tRPC's automatic error formatting, gains direct narrowing and no runtime dependency on ata-validator.

Error shape

tRPC surfaces validation failures as TRPCError with code BAD_REQUEST. ata's Standard Schema issues array maps to tRPC's default error formatter. Each issue has message and path:

{
  "code": "BAD_REQUEST",
  "issues": [
    { "message": "must be >= 1", "path": ["id"] }
  ]
}

Override via errorFormatter in initTRPC.create() if the default is too verbose.

Notes

  • Versions before tRPC v11 do not support Standard Schema directly. For v10, wrap the Validator manually in a function that matches the legacy validator contract.
  • Using ata compile for both the runtime validator and types keeps the schema file as the single source of truth.
  • abortEarly mode works as input validator but discards the detailed issues list, so the client only sees "validation failed". Good for high-throughput internal endpoints, bad for public API with informative error responses.