ReadOnly and WriteOnly Properties
@apical-ts/craft provides comprehensive support for OpenAPI's readOnly and
writeOnly properties, enabling type-safe handling of properties that should
only appear in requests or responses.
Overview
The readOnly and writeOnly keywords in OpenAPI schemas define property
access patterns:
readOnly: true: Property is included only in responses, excluded from requestswriteOnly: true: Property is included only in requests, excluded from responses
This feature is essential for APIs that have server-computed fields (like IDs, timestamps) or sensitive request-only fields (like passwords).
How It Works
Schema Variants
When a schema contains readOnly or writeOnly properties, the generator
automatically creates specialized variants:
- Base Schema: The complete schema with all properties (used for documentation and type inference)
- Request Variant (
*Request): ExcludesreadOnlyproperties, used for request body types - Response Variant (
*Response): ExcludeswriteOnlyproperties, used for response types
The variants are independently generated schemas that exclude properties at schema construction time:
// Generated base schema with all properties
export const User = z.object({
id: z.string(), // readOnly
createdAt: z.string(), // readOnly
username: z.string(),
email: z.string(),
password: z.string(), // writeOnly
});
export type User = z.infer<typeof User>;
// Auto-generated Request variant (excludes readOnly fields)
export const UserRequest = z.object({
username: z.string(),
email: z.string(),
password: z.string(),
});
export type UserRequest = z.infer<typeof UserRequest>;
// Auto-generated Response variant (excludes writeOnly fields)
export const UserResponse = z.object({
id: z.string(),
createdAt: z.string(),
username: z.string(),
email: z.string(),
});
export type UserResponse = z.infer<typeof UserResponse>;
Import Support
The variants can be imported directly from their own files or from the base schema file:
// Both imports work identically
import { UserRequest } from "./schemas/User.js";
import { UserRequest } from "./schemas/UserRequest.js";
// Response variant
import { UserResponse } from "./schemas/User.js";
import { UserResponse } from "./schemas/UserResponse.js";
Client Operations
Client operations automatically use the appropriate variant based on context:
Request Bodies
Operations use the Request variant (excludes readOnly properties):
import { createUser } from "./generated/operations/createUser.js";
// ✅ Valid - no readOnly fields required
const result = await createUser({
body: {
username: "alice",
email: "alice@example.com",
password: "secret123",
},
});
// ❌ Type Error - readOnly fields not allowed
const invalid = await createUser({
body: {
id: "123", // Error: readOnly property excluded from UserRequest
username: "alice",
},
});
Response Types
Operations return response types that exclude writeOnly properties:
const result = await getUser({ id: "123" });
if (result.success) {
const user = result.value.data;
console.log(user.id); // ✅ Available (readOnly field in response)
console.log(user.username); // ✅ Available
console.log(user.password); // ❌ Type Error - writeOnly not in response
}
OpenAPI Schema Example
components:
schemas:
User:
type: object
properties:
id:
type: string
readOnly: true
description: Auto-generated user ID
createdAt:
type: string
format: date-time
readOnly: true
description: Account creation timestamp
username:
type: string
email:
type: string
password:
type: string
writeOnly: true
description: Password (never returned from API)
required:
- id
- username
paths:
/users:
post:
operationId: createUser
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/User"
responses:
"201":
description: User created
content:
application/json:
schema:
$ref: "#/components/schemas/User"
Nested Objects
ReadOnly and writeOnly properties are correctly handled in nested objects. The
filtering occurs during schema generation itself, not through .omit()
variants.
When a schema context (request, response, or base) is propagated through nested objects, readOnly and writeOnly properties are excluded during the nested object's schema construction:
components:
schemas:
Product:
type: object
properties:
sku:
type: string
name:
type: string
metadata:
type: object
properties:
createdBy:
type: string
readOnly: true
internalNotes:
type: string
writeOnly: true
Generated code:
// Base schema includes all properties
export const Product = z.object({
sku: z.string(),
name: z.string(),
metadata: z.object({
createdBy: z.string(),
internalNotes: z.string(),
}),
});
// Request variant - nested metadata excludes readOnly createdBy property
export const ProductRequest = z.object({
sku: z.string(),
name: z.string(),
metadata: z.object({
internalNotes: z.string(),
}),
});
// Response variant - nested metadata excludes writeOnly internalNotes property
export const ProductResponse = z.object({
sku: z.string(),
name: z.string(),
metadata: z.object({
createdBy: z.string(),
}),
});
Using Variants Directly
You can use Request and Response variants in your own code for validation:
import { User, UserRequest, UserResponse } from "./generated/schemas/User.js";
// Validate incoming request data
const requestData = UserRequest.parse(rawInput);
// Validate API response
const responseData = UserResponse.parse(apiResponse);
// Full schema for documentation
const fullSchema = User;
Type Safety Benefits
The variant approach provides several advantages:
- Compile-time checks: TypeScript catches attempts to send readOnly fields in requests
- Runtime validation: Zod schemas validate the correct fields are present
- Clear semantics: Type names (
UserRequest,UserResponse) make intent explicit - No bloat: Only relevant properties are included in nested schemas
- Better errors: Validation failures only mention relevant fields
- Generation-time filtering: Nested readOnly/writeOnly properties are filtered during schema construction, not at validation time
Mixed Scenarios
Schemas can have any combination of properties:
// Base schema with everything
export const User = z.object({
id: z.string(), // readOnly
username: z.string(),
email: z.string(),
password: z.string(), // writeOnly
twoFactorSecret: z.string(), // writeOnly
});
// Request - has username, email, password, twoFactorSecret; no id
export const UserRequest = z.object({
username: z.string(),
email: z.string(),
password: z.string(),
twoFactorSecret: z.string(),
});
// Response - has id, username, email; no password, twoFactorSecret
export const UserResponse = z.object({
id: z.string(),
username: z.string(),
email: z.string(),
});
Server-Side Usage
If using the generated schemas for server-side validation, use the appropriate variant:
import { UserRequest, UserResponse } from "./generated/schemas/User.js";
// Validate incoming request body
app.post("/users", (req, res) => {
const data = UserRequest.parse(req.body);
// data contains: username, email, password (no id, createdAt)
const newUser = createUserInDb(data);
// Return with response type (id, username, email are included)
res.json(UserResponse.parse(newUser));
});
Best Practices
- Use Request variants for client requests - Ensures only the right fields are sent
- Use Response variants for API responses - Ensures you don't accidentally expose secret fields
- Leverage type checking - Let TypeScript catch misuse at compile time
- Validate at boundaries - Use Zod's
.parse()when processing external data - Document intent - Use
descriptionfields to explain why properties are readOnly/writeOnly
Common Patterns
Server-Generated IDs
User:
properties:
id:
type: string
readOnly: true
name:
type: string
Timestamps
User:
properties:
createdAt:
type: string
format: date-time
readOnly: true
updatedAt:
type: string
format: date-time
readOnly: true
Sensitive Input Fields
User:
properties:
password:
type: string
writeOnly: true
apiKey:
type: string
writeOnly: true
Complex Metadata
Product:
properties:
sku:
type: string
metadata:
type: object
properties:
internal:
type: string
writeOnly: true
analytics:
type: object
writeOnly: true
Migration Guide
If you have existing code without readOnly/writeOnly properties:
- Update your OpenAPI schema to mark properties with
readOnly: trueorwriteOnly: true - Regenerate your client code
- Update imports to use
*Requestand*Responsevariants where needed - TypeScript will highlight any type mismatches to fix
The generator maintains backward compatibility - schemas without readOnly/writeOnly properties work exactly as before.