TypeScript client
./gradlew elcore3ts writes a TypeScript client for the screens to src/main/ts, from the same
model as the server: DTOs, enums with their labels, a typed API client per exposed root, validation
functions with the same words as the server's 400, and canX(dto) for the lifecycle's buttons.
./gradlew elcore3ts
build and compileJava do not run that task. To have the build write the client too:
elasticore {
architecture = 'hexagonal'
typescriptClient = true // default false: runs elcore3ts after elcore3, before compiling
}
Without the setting, when src/main/ts holds a generated client made from an older model, elcore3
warns once at the end: "... holds a generated TypeScript client made from an older model - run the
elcore3ts task after a model change, or set typescriptClient = true ...".
What is generated (pet store)
src/main/ts/
ItemDTO.ts, ItemSrchDTO.ts, ItemKeyDTO.ts interfaces, field limits, validateItem()
ItemApi.ts create, findById, search, update, ... per endpoint
PurchaseOrderApi.ts ship, cancel, canShip, canCancel, canDelete
CustomerApi.ts signIn, signOut, me (from @signin)
OrderStatus.ts, UserRole.ts, CreditCardType.ts enums and their labels
Address.ts, CreditCard.ts value objects
PurchaseOrderInvariants.ts violations(), isValid() - a check before sending
apiSupport.ts request(), Page<T>, ProblemDetail, ApiError
A DTO and its validation, from unitCost: money(10.2)! @min(0.01, 'price must be positive'):
export interface ItemDTO {
productName?: string;
itemId?: number;
name: string;
description: string;
imagePath?: string;
unitCost: number;
productId?: number;
}
/** Each field's label and limits, as the DSL declares them. */
export const ItemFields = {
name: { label: "name", required: true, maxLength: 30 },
unitCost: { label: "unitCost", required: true, min: 0.01 },
// ...
} as const;
export function validateItem(value: Partial<ItemDTO> | null | undefined, partial = false): Record<string, string> {
// ...
if (!partial && value.unitCost == null) errors.unitCost = "unitCost is required";
else if (value.unitCost != null && Number(value.unitCost) < 0.01) errors.unitCost = "price must be positive";
return errors;
}
An enum with what a screen shows for each value:
export const OrderStatus = {
PLACED: "PLC",
SHIPPED: "SHP",
CANCELLED: "CAN"
} as const;
export const OrderStatusLabel: Record<OrderStatus, string> = {
[OrderStatus.PLACED]: "Placed",
[OrderStatus.SHIPPED]: "Shipped",
[OrderStatus.CANCELLED]: "Cancelled",
};
The lifecycle's buttons:
export function canCancel(value: PurchaseOrderDTO | null | undefined): boolean {
return value != null && (value.status === OrderStatus.PLACED);
}
Errors
Every non-2xx answer becomes an ApiError with the server's problem detail: code to branch on
(not-found, invalid, state.PurchaseOrder.status.cancel ...) and fieldErrors (field ->
message) for a form.
try {
await Orders.cancel(id);
} catch (e) {
if (e instanceof ApiError) showError(e.problem?.detail, e.fieldErrors);
}
Sign-in is a cookie session, so the screens and the API are served from the same origin.
What reaches the browser
The TypeScript model follows the server's choice: id references (customerId) and inline value
objects (deliveryAddress) when the Java or Kotlin rules use them. XxxApi.ts leaves out the
endpoints the controller does not have (@readonly, @restExclude, the update of a root that only
takes commands); @writeonly fields are optional, since answers never carry them. Specifications and
invariants come along as functions - a check before sending, not enforcement: the server still
refuses. Money is declared number, and generated comparisons use Number(...), which is right
whether the server sends a decimal as a JSON number or as a string.
The pet store's screen (web/src) uses the generated labels (OrderStatusLabel), validation
(validateAddress), server errors (ApiError.fieldErrors) and button checks (canCancel); its
webBuild task type-checks it against the client.
Because server and screen come from the same DSL, a changed field shows up in the screen code as a compile error.