Validation
Validate incoming requests
When receiving incoming requests it's best practice to validate the payload to ensure it meets your expectations and includes all required fields.
Encore.ts has request validation built in, designed to work seamlessly with TypeScript. It uses the natural TypeScript types directly to validate incoming requests, so you get the best of both worlds: the clean, concise TypeScript syntax, and runtime schema validation. This means your APIs are type-safe during both runtime and compile-time.
Encore.ts makes it easy to define API endpoints that combine data from different sources: some fields from the request body, others from the query parameters, and yet others from the HTTP headers. It looks like this:
import { Header, Query, api } from "encore.dev/api";
interface Request {
// Optional query parameter. Parsed from the request URL.
limit?: Query<number>;
// Custom header that must be set. Parsed from the HTTP headers.
myHeader: Header<"X-My-Header">;
// Required enum. Parsed from the request body.
type: "sprocket" | "widget";
}
export const myEndpoint = api<Request, Response>(
{ expose: true, method: "POST", path: "/api" },
async ({ limit, myHeader, type }) => {
// ...
},
);
In the above example, if a request to the endpoint is missing the X-My-Header
HTTP header or the type
field in the request body, Encore will return a 400
Bad Request response. The limit
query parameter is optional and will be either a number or undefined
in the endpoint handler function.
Supported validation types
String fields
interface Schema {
name: string;
}
Number fields
The number
type will accept both int and float values:
interface Schema {
age: number;
}
Boolean fields
interface Schema {
isHuman: boolean;
}
Array fields
You can define fields wit array values of string
, number
, boolean
, null
:
interface Schema {
strings: string[];
numbers: number[];
booleans: boolean[];
nulls: null[];
}
You can have an array of objects:
interface Schema {
users: { name: string; age: number }[];
}
It is also possible to have arrays of multiple types:
interface Schema {
values: (string | number)[];
}
Enum fields
String enums can be used in the request schema to validate that a field is one of a set of predefined values. For example:
interface Schema {
type: "BLOG_POST" | "COMMENT"
}
You can also use TypeScript enums:
enum PostType {
BlogPost = "BLOG_POST",
Comment = "COMMENT"
}
interface Schema {
type: PostType,
}
The two above examples are equivalent.
Optional fields
You can make a field optional by adding a ?
after the field name:
interface Request {
name?: string;
}
export const myEndpoint = api(
{ expose: true, method: "POST", path: "/body" },
async (req: Request) => {
// req.name is a string or undefined
},
);
Request will reach the endpoint handler even if the name
field is missing. If the field is missing, the value will be undefined
.
Nullable fields
You can make a field nullable by using the | null
type:
interface Request {
name: string | null;
}
export const myEndpoint = api(
{ expose: true, method: "POST", path: "/body" },
async (req: Request) => {
// req.name is a string or null
},
);
Union fields
You can define a field that can be one of several types by using a union type:
interface Request {
value: string | number | boolean;
}
export const myEndpoint = api(
{ expose: true, method: "POST", path: "/body" },
async (req: Request) => {
// req.value is a string, number, or boolean
},
);
Reference schema
interface Schema {
str: string; // String
int: number; // Number
list: number[]; // Array of numbers
listOfTypes: (number | string )[]; // Array multiple types
nullable: number | null; // Nullable
maybe?: string; // Optional
multiple: boolean | number | string | { name: string }; // Union
enum: "John" | "Foo"; // Enum
}
Please note
For other types of validation, like checking the format of an email address or the length of a string, you need to implement those yourself inside the endpoint handler. More types of built-in validation will be added in the future.
Body
By default, the data is parsed as a JSON body for incoming requests:
interface Request {
name: string; // Parsed from the JSON body
}
export const myEndpoint = api<Request, Response>(
{ expose: true, method: "POST", path: "/body" },
async (req) => {
// req.name is a string
},
);
Here, name
is a required field in the request body. If the request body is missing the name
field, Encore will return a 400
Bad Request response.
Query
For HTTP methods that support request bodies, parameters are by default read from the HTTP request body as JSON. In those cases, the Query
type can be used to specify that a field should be parsed from the query string instead.
import { api, Query } from "encore.dev/api";
interface Schema {
query: Query<string>; // this will be parsed from the '?query=...' parameter in the request url
}
// A simple API endpoint that echoes the data back.
export const echo = api(
{ method: "POST", path: "/example" },
async (params: Schema) => {
// params.query is a string
},
);
This API endpoint expects incoming requests to look like this:
POST /example?query=hello HTTP/1.1 Content-Type: application/json
For GET
, HEAD
and DELETE
requests, parameters are read and validated from the query string by default, since those HTTP methods do not support request bodies. For those methods, the Query
type is not necessary:
import { Query } from "encore.dev/api";
interface Schema {
limit: Query<number>; // always a query parameter
author: string; // query if GET, HEAD or DELETE, otherwise body parameter
}
Nested query fields
Using the Query
type as a nested fields has no effect:
import { api, Query } from "encore.dev/api";
interface Data {
query: Query<string>; // this will be parsed from the '?query=...' parameter in the request url
nested: {
query2: Query<string>; // Query has no effect inside nested fields
};
}
export const echo = api(
{ method: "POST", path: "/nested" },
async (params: Data) => {
// ...
},
);
Nested query params will be sent as part of the JSON body. The above endpoint expects incoming requests to look like this:
POST /nested HTTP/1.1 Content-Type: application/json { "nested": { "query2": "not a query string" } }
Headers
Request headers are defined and validated by setting the field type to Header<"Name-Of-Header">
. It can be used in both request and response data types.
In the example below, the language
field will be fetched from the Accept-Language
HTTP header. If the request is missing the Accept-Language
header, Encore will return a 400
Bad Request response.
import { Header } from "encore.dev/api";
interface Params {
language: Header<"Accept-Language">; // parsed from header
author: string; // not a header
}
Nested header fields
Using the Header
type as a nested fields has no effect:
import { api, Header } from "encore.dev/api";
interface Data {
header: Header<"X-Header">; // this field will be read from the http header
nested: {
header2: Header<"X-Other-Header">; // Header has no effect inside nested fields
};
}
// A simple API endpoint that echoes the data back.
export const echo = api(
{ method: "POST", path: "/nested" },
async (params: Data) => {
// ...
},
);
Nested headers will be sent as part of the JSON body. The above endpoint expects incoming requests to look like this:
POST /nested HTTP/1.1 Content-Type: application/json X-Header: this is a header { "nested": { "header2": "not a header", } }
Params
Dynamic path parameters are also defined in the request schema. The parameter will be parsed from the request URL and made available in the request object:
import { api } from "encore.dev/api";
interface Request {
// Required path parameter. Parsed from the request URL.
id: string;
}
export const myEndpoint = api(
{ expose: true, method: "POST", path: "/user/:id" },
async ({ id }: Request) => {
// ...
},
);
You can also use the number
type for path parameters:
interface Request {
id: number;
}
Encore.ts will then try to parse the path parameter as a number. If the path parameter is not a valid number, Encore will return a 400
Bad Request response.
Combining sources
You can combine data from different sources in the same request schema. For example, you can have fields that are parsed from the request body, others from the query parameters, and yet others from the HTTP headers. It looks like this:
import { Header, Query, api } from "encore.dev/api";
interface Request {
// Required path parameter. Parsed from the request URL.
id: number;
// Optional query parameter. Parsed from the request URL.
limit?: Query<number>;
// Custom header that must be set. Parsed from the HTTP headers.
myHeader: Header<"X-My-Header">;
// Required enum. Parsed from the request body.
type: "sprocket" | "widget";
}
export const myEndpoint = api(
{ expose: true, method: "POST", path: "/user/:id" },
async ({ id, limit, myHeader, type }: Request) => {
// ...
},
);
Errors
If the validation is not successful, Encore will return a 400
Bad Request response with a JSON body that contains the error message:
HTTP/1.1 400 Bad Request { "code": "invalid_argument", "message": "unable to decode request body", "internal_message": "Error(\"missing field name\", line: 1, column: 18)" }
Response
Encore.ts will not perform runtime validation for response data, but you will get compilation errors if you try to return a value that does not match the expected response type.
Reusing the request type as the response type
You often want to return the same data type that you received in a request. In this case, you can reuse the request type as the response type:
import { api, Header, Query } from "encore.dev/api";
interface Data {
header: Header<"X-Header">; // this field will be read from the http header
query: Query<string>; // this will be parsed from the '?query=...' parameter in the request url
body: string; // this will be sent as part of the JSON body
}
// A simple API endpoint that echoes the data back.
export const echo = api(
{ method: "POST", path: "/echo" },
async (params: Data): Promise<Data> => {
return params; // echo the data back
},
);
This API endpoint expects incoming requests to look like this:
POST /echo?query=hello HTTP/1.1 Content-Type: application/json X-Header: this is a header { "body": "a body", }
For HTTP responses the Query<string>
type is considered to be part of the JSON response body, since query strings only make sense for incoming requests.
Responses returned from this endpoint will be serialized as a HTTP response to looks like this:
HTTP/1.1 200 OK Content-Type: application/json X-Header: this is a header { "query": "hello", "body": "a body", }
Under the hood
Encore.ts parses your source code to understand the request and response schema that each API endpoint expects, including things like HTTP headers, query parameters, and so on. The schemas are then processed, optimized, and stored as a Protobuf file.
When the Encore.ts Rust runtime starts up, it reads the Protobuf file and pre-computes a request decoder and response encoder, optimized for each API endpoint, using the exact type definition each API endpoint expects. In fact, Encore.ts even handles request validation directly in Rust, ensuring invalid requests never have to even touch the JavaScript layer, mitigating many denial of service attacks.
Encore’s understanding of the request schema also improves performance. JavaScript runtimes like Deno and Bun use a similar architecture (in fact, Deno also uses Rust+Tokio+Hyper), but lack Encore’s understanding of the request schema. As a result, they need to hand over the un-processed HTTP requests to the single-threaded JavaScript engine for execution.
Encore.ts, on the other hand, handles much more of the request processing inside Rust, and only hands over the decoded request objects. By handling much more of the request life-cycle in multi-threaded Rust, the JavaScript event-loop is freed up to focus on executing application business logic instead of parsing HTTP requests, resulting in a significant performance improvement.