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.