Skip to content

Get started with Arc for TypeScript

Say you need a small task API: register a task, list the tasks, and refuse a task without a title. In a plain Node.js server that means a route per operation, body parsing, a validation response format, status-code mapping, and a client that has to know all of it. Every endpoint repeats the same plumbing, and every rename risks breaking the client.

With Arc you write the parts that are yours: a command class that registers a task, a validator with the rules, and a read model with the queries. Arc serves them over HTTP, checks input before your code runs, and wraps every answer in the same result shape.

In this walkthrough you run the Tasks sample from this repository and talk to it with curl. By the end you have a server on 127.0.0.1:3000 that accepts a command, rejects invalid input before the handler runs, and serves the tasks you stored. No event store or database is involved.

You need Node.js 22.19 or later, Git, Corepack, and curl. Node.js 25 and later no longer include Corepack; if corepack enable reports that the command is not found, run npm install --global corepack first.

Terminal window
git clone https://github.com/Cratis/Arc.TypeScript.git
cd Arc.TypeScript
corepack enable
yarn install
yarn build
yarn workspace @cratis/arc.core.sample.tasks start

The server listens on 127.0.0.1:3000 and keeps its tasks in memory, so a restart clears them. Set PORT to use another port. It runs until you press Ctrl+C or send SIGTERM, then closes its connections gracefully.

Leave it running and open a second terminal for the rest of this page.

Send the RegisterTask command with any UUID and a title:

Terminal window
curl -X POST http://127.0.0.1:3000/api/tasks/registration/register-task \
-H 'content-type: application/json' \
-d '{"id":"1a638f8e-4444-4444-8888-a0b10cdd9977","title":"Write a guide"}'

The answer is HTTP 200 with a command result. The correlation ID differs on every request:

{"correlationId":"0c2d6872-c3cb-4a84-af93-1084caa4d22d","isAuthorized":true,"validationResults":[],"exceptionMessages":[],"exceptionStackTrace":"","authorizationFailureReason":"","isValid":true,"hasExceptions":false,"isSuccess":true,"response":"1a638f8e-4444-4444-8888-a0b10cdd9977"}

Nobody wrote that route. Arc derived /api/tasks/registration/register-task from the folder the command lives in and its class name. It parsed the body against the command’s declared fields, turned the ID string into a typed TaskId, ran the validators, and called the command’s handle() method. The response is the ID that handle() returned, encoded back to a string. The flags (isSuccess, isValid, isAuthorized, hasExceptions) are the same on every command, so a client checks one shape everywhere.

Now send a task with an empty title:

Terminal window
curl -X POST http://127.0.0.1:3000/api/tasks/registration/register-task \
-H 'content-type: application/json' \
-d '{"id":"2b638f8e-4444-4444-8888-a0b10cdd9977","title":""}'

The answer is 400, and validationResults holds one entry:

{"severity":3,"message":"A title is required","members":["title"],"reason":"rule"}

That message comes from the sample’s validator, and members tells a form which field to mark. The handler never ran, so nothing was stored.

Leave title out of the body entirely and you get a different 400: one result with reason malformedRequest and no members. Arc separates a request with the wrong shape (a missing field, a wrong type, broken JSON) from a request that breaks a rule a user can fix. Only rules carry a message meant for a person.

A frontend often wants to check a rule before the user presses Save. Every command has a second route for that: POST <command-route>/validate runs authorization and validation, then stops:

Terminal window
curl -X POST http://127.0.0.1:3000/api/tasks/registration/register-task/validate \
-H 'content-type: application/json' \
-d '{"id":"2b638f8e-4444-4444-8888-a0b10cdd9977","title":"!Loud"}'

The answer is 400 with A title cannot begin with an exclamation mark for the member title. That rule belongs to the task title itself, wherever it appears. Your first command shows where each rule lives.

Queries are GET requests:

Terminal window
curl http://127.0.0.1:3000/api/tasks/listing/all-tasks
curl 'http://127.0.0.1:3000/api/tasks/listing/task-by-id?id=1a638f8e-4444-4444-8888-a0b10cdd9977'

The first answer’s data is an array holding { "id": "1a638f8e-4444-4444-8888-a0b10cdd9977", "title": "Write a guide" }. The second answers with that object directly. Arc bound id from the query string by name and converted it to a TaskId; a missing or invalid UUID answers 400 with malformedRequest. An ID nobody registered answers 200 without a data property: absence is an answer, not an error.

Add ?pageSize=1&sortBy=title to the first URL and Arc pages and sorts the list for you, reporting the totals in paging. The query method itself only returns an array.

The whole entry point is Samples/Tasks/main.ts:

Samples/Tasks/main.ts
import { ArcApplication } from '@cratis/arc.core';
import { Tasks } from './Features/Tasks/Tasks.js';
import { metadata } from './Features/generatedMetadata.js';
// The Yarn start script selects Development; appsettings.json only opts into fixture providers.
const builder = ArcApplication.createBuilder();
builder.useGeneratedMetadata(metadata);
builder.services.addSingleton(Tasks);
await builder.discover(new URL('./Features/', import.meta.url));
export const app = await builder.build();
await app.run({ port: Number(process.env.PORT ?? 3000) });

Each line has one job:

LineWhat it does
createBuilder()Starts an Arc application and reads optional appsettings.json (Cratis:Arc) and Cratis__... environment variables
useGeneratedMetadata(metadata)Installs parameter and return-type information that the proxy generator extracted from the source, because TypeScript erases types at runtime
services.addSingleton(Tasks)Registers the in-memory store that the command and queries receive
discover(...)Imports the modules under Features/ (skipping for_* spec folders) and picks up commands, read models, and validators by their decorators
build()Checks the whole graph (every injected service registered, no lifetime mismatches, no misplaced decorators) before any listener opens
run(...)Starts the standalone Node.js host and maps every route

If you forget to register Tasks, build() throws Missing service: Tasks at startup, instead of the first request failing in production.

The sample’s appsettings.json sets Cratis:Arc:Development to true to enable fixture providers. The start script separately sets DOTNET_ENVIRONMENT=Development using Yarn 4’s cross-platform script shell. That environment enables anonymous discovery and exception details for local development; do not use this start script on an exposed host. Configuration lists every setting.

To serve the same artifacts from Express, Fastify, or Hono instead of the standalone host, you keep the builder and hand its routes to the framework’s adapter; see the hosting overview.

The sample’s Development host describes itself anonymously. GET /.cratis/commands and GET /.cratis/queries list every operation with its route and the JSON Schema of its input, and GET /openapi.json returns an OpenAPI 3.1 document. Outside Development these endpoints follow the discovery access policy:

Terminal window
curl http://127.0.0.1:3000/.cratis/commands

The first entry names RegisterTask, its route, and a schema that requires id as a UUID and title as a string. Introspection and OpenAPI cover both.

You started a server with no routing code. A command registered a task, Arc refused an empty title with a message for the right field, a query returned the stored task, and paging and sorting came for free. The entry point is six statements, and build() checked the wiring before the server accepted a request.

The concepts carry over; the spelling is TypeScript:

Arc on .NETArc for TypeScript
ArcApplication.CreateBuilder(args)ArcApplication.createBuilder()
[Command] record with Handle()@command() class with handle()
[ReadModel] record with static query methods@readModel() class with static @query() methods
Assembly discoverybuilder.discover(folderUrl) or builder.add(...)
app.UseCratisArc() and RunAsync()app.run() on the standalone host, or a framework adapter

Coming from Express and NestJS compares Arc with the Node.js code you may write today.

Open the sample and read it file by file in Your first command. After that, Continue in the browser generates a typed client and calls this server from a React page. When you want a project of your own, Create an application sets one up from an empty folder.