ESLint

Catch Kizuna mistakes in your editor with the official ESLint plugin.

@kizunajs/eslint-plugin catches Kizuna mistakes in your editor, as you type. These are things the type system can't express on its own.

pnpm add @kizunajs/eslint-plugin@beta

Setup

Add the recommended config to your eslint.config.js, alongside whatever else you already run:

eslint.config.js
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import kizuna from '@kizunajs/eslint-plugin';

export default [js.configs.recommended, ...tseslint.configs.recommended, kizuna.configs.recommended];

Turning a rule off

Override it after the recommended config:

eslint.config.js
import kizuna from '@kizunajs/eslint-plugin';

export default [
    kizuna.configs.recommended,
    {
        rules: {
            '@kizunajs/no-unsupported-schema': 'off',
        },
    },
];

Or silence a single line inline:

query: z.object({
    // eslint-disable-next-line @kizunajs/no-unsupported-schema
    page: z.coerce.number(),
}),

no-unsupported-schema

Flags anything no generated client can produce a type for: body, query, pathParams, headers, and response schemas in your routes, plus Kizuna.model schemas, inline or imported. It follows imports, so the error lands on the offending field when it's local, or on the reference when the schema comes from another file.

z.coerce

Kizuna already coerces query, path, and header params to their declared types (see Coercion), so z.coerce is redundant, and k.routes throws on it. Use the plain schema:

// flagged
query: z.object({
    page: z.coerce.number(),
});

// good, kizuna coerces the string for you
query: z.object({
    page: z.number(),
});

z.set and z.map

JSON has neither, so JSON.stringify(new Set(['a'])) is {} and the value never reaches the caller. The generated clients cannot name a type for something the wire does not carry:

// flagged
body: z.object({
    tags: z.set(z.string()),
    counts: z.map(z.string(), z.number()),
});

// good, both survive the round trip
body: z.object({
    tags: z.array(z.string()),
    counts: z.record(z.string(), z.number()),
});

A union of objects with no discriminator

Two object branches with overlapping fields cannot be told apart from the JSON, so a decoder picks whichever matches first, and which one that is depends on the order you declared them. z.discriminatedUnion puts the answer on the wire:

// flagged
body: z.union([CircleSchema, SquareSchema]);

// good, `kind` says which branch arrived
body: z.discriminatedUnion('kind', [CircleSchema, SquareSchema]);

A union of different kinds is untouched, so the one-or-many query param that accepts a single value or a list still works:

query: z.object({
    ids: z.union([z.array(z.string()), z.string()]),
});

A transform on a response body

A transform's output is whatever the function returns, which no generator can read, so the client would be typed from the input and be wrong about what it receives. Declare the response as the shape it sends:

// flagged
responses: {
    200: z.object({
        length: z.string().transform((value) => value.length),
    }),
}

// good
responses: {
    200: z.object({
        length: z.number(),
    }),
}

A transform on the way in is fine, since the caller sends the input and the server parses it.

A nullable query, path, header or form field

A query string, a path, a header and a form field have no null, so every client leaves a null out, and the server rejects the missing key of a .nullable() field. Declare it .optional():

// flagged
query: z.object({
    cursor: z.string().nullable(),
});

// good, a caller leaves it out
query: z.object({
    cursor: z.string().optional(),
});

JSON bodies and responses can carry null, so .nullable() there is untouched. See Optional and null fields.

Across files

A schema defined elsewhere, such as a shared PaginationQuery in its own zod-only module, is still caught, and the error lands on the reference:

pagination.ts
import { z } from 'zod';

export const PaginationQuery = z.object({
    page: z.coerce.number(),
});
users.ts
import { PaginationQuery } from './pagination.js';

export const users = k.routes.users({
    listUsers: k
        .route({
            method: 'GET',
            path: '/users',
            query: PaginationQuery, // flagged here, PaginationQuery uses z.coerce
            responses: {
                200: UserListSchema,
            },
        })
        .handler(/* ... */),
});