Connecting Power Pages React and Dataverse with typed interfaces
Typed Power Pages interfaces connect React and Dataverse through controllers, services, repositories, transport and validated runtime parsers.
Contents
- Which transport interfaces does React need in Power Pages?
- Why TypeScript does not validate network data in Power Pages
- Why do three Power Pages operations need separate request contracts?
- What are the responsibilities of controllers services and repositories in React?
- How does a Power Pages transport encapsulate the runtime?
- Which JSON contracts does a Power Pages response have?
- How does an operation parser prevent mismatched Power Pages responses?
- How deeply must Power Pages results be validated?
- How do parser errors remain visible in React and Power Pages?
- How do validated search results become a React consumer contract?
- When do typed React and Dataverse interfaces justify the effort?
- Typed React and Dataverse interfaces in the enterprise project
Which transport interfaces does React need in Power Pages?
The technical contract between my React search and Power Pages begins with two methods:
export interface ISearchTransport {
submitSearchFilters(request: SearchServerLogicRequest): Promise<SearchApiData<SearchResponse>>;
fetchSuggestions(request: SuggestServerLogicRequest): Promise<readonly SearchSuggestion[]>;
}This interface shows the two operations consumed by the existing React search widget itself.
The shared HTTP endpoint also provides getPortalCatalogDetails. This third contract belongs to detail and favourites views. It is typed without adding an unused method to the Search Service.
The interface knows nothing about jQuery callbacks. It knows no Server Logic endpoint. It knows no Dataverse connector details.
The application layers see only a request and a Promise with a defined result.
The public examples use a small generalized domain model:
type SupportedLcid = 1031 | 1033;
type SearchSuggestionKind = 'city' | 'postalCode' | 'region' | 'center';
interface FilterPayload {
readonly lcid: SupportedLcid;
readonly searchText?: string;
}
interface SearchResult {
readonly id: string;
readonly name: string;
}
interface SearchResponse {
readonly lcid: SupportedLcid;
readonly objects: readonly SearchResult[];
}
interface SearchApiData<TContent> {
readonly IsSucceeded: boolean;
readonly Content: TContent;
}
interface SearchSuggestion {
readonly kind: SearchSuggestionKind;
readonly label: string;
readonly value: string;
readonly isDirectMatch: boolean;
}This small boundary is the starting point of my Power Pages React Dataverse Server Logic architecture. The enterprise search overview places it in the complete data flow.
Why TypeScript does not validate network data in Power Pages
TypeScript can ensure that my service passes only a SearchServerLogicRequest to the transport. It can prevent a consumer from accidentally expecting a mutable array.
TypeScript cannot check what the server actually returns.
Interfaces and types are removed during the build. An expression like this therefore performs no runtime validation:
const response = JSON.parse(rawValue) as SearchResponse;The cast changes only the compiler’s view. Incorrect JSON remains incorrect JSON.
The TypeScript documentation on narrowing shows how runtime checks derive a reliable type from unknown.
I therefore keep network data as unknown until a parser checks the expected shape and then returns a domain type.
readonly also has a clear limit. It prevents unwanted assignments in TypeScript code. It does not automatically freeze an object at runtime.
Aspect | TypeScript type | Runtime parser |
|---|---|---|
Timing | Build and development | After receiving actual network data |
Input | Known values in your own code |
|
Validation | Calls, fields and return types | Object shape, discriminator and required values |
Limit | No longer present after the build | Checks only explicitly implemented rules |
Effect of errors | Compiler reports contradictory code | Parser visibly rejects invalid data |
Why do three Power Pages operations need separate request contracts?
Search and auto-suggest use the same Server Logic endpoint. They nevertheless have different contracts.
export type SearchServerLogicRequest = {
readonly operation: 'search';
} & FilterPayload;
export interface SuggestServerLogicRequest {
readonly operation: 'suggest';
readonly lcid: SupportedLcid;
readonly searchText: string;
}
export interface PortalCatalogDetailsServerLogicRequest {
readonly operation: 'getPortalCatalogDetails';
readonly entityType: 'center' | 'area';
readonly ids: readonly string[];
readonly languageCode: 'de' | 'de-DE' | 'en' | 'en-US' | 'en-GB';
}
export type ServerLogicRequest =
| SearchServerLogicRequest
| SuggestServerLogicRequest
| PortalCatalogDetailsServerLogicRequest;The operation field is the discriminator.
A complete search can carry all supported filters. A Suggest request contains only language and search text. A detail batch contains an entity type, a language and known IDs.
I do not use a large request type with many optional fields. Such a type would allow combinations that make no business sense.
The service explicitly creates the operation:
class SearchService {
constructor(private readonly repository: SearchRepository) {}
submitSearchFilters(payload: FilterPayload) {
return this.repository.submitSearchFilters({
operation: 'search',
...payload,
});
}
fetchSuggestions(searchText: string, lcid: SupportedLcid) {
return this.repository.fetchSuggestions({
operation: 'suggest',
lcid,
searchText,
});
}
}The Search Service still adds only search and suggest because the existing widget does not render a favourites page. The complete endpoint type nevertheless includes the third contract for a separate browser consumer.
The deep dive on Power Pages favourites with batch GUIDs and Server Logic shows that contract.
The caller therefore cannot forget the operation. The return type remains linked to the selected method.
Aspect | Complete search | Auto-suggest | Detail batch |
|---|---|---|---|
Discriminator | operation: ‘search’ | operation: ‘suggest’ | operation: ‘getPortalCatalogDetails’ |
Required values | lcid |
|
|
Optional values | Search text and supported filters | No additional search filters | No search filters |
Return value | SearchApiData<SearchResponse> | readonly SearchSuggestion[] | PortalCatalogDetailsData |
Business purpose | Result list from all accepted filters | Small suggestion list for search text input | Load known centers or areas together |
The deep dive on Power Pages auto-suggest with React, debounce and requests follows this smaller contract from the input field to the grouped and validated suggestion list.
What are the responsibilities of controllers services and repositories in React?
I do not separate layers because of their names. Each layer must own a concrete decision.
The controller validates UI input. It trims search text, returns validation errors as a discriminated union and prepares a serialized search contract.
type SearchPreparationResult =
| {
readonly isValid: false;
readonly validationErrors: readonly string[];
}
| {
readonly isValid: true;
readonly prepared: {
readonly payload: FilterPayload;
readonly queryString: string;
};
};The contract reduced here to payload and queryString lives under serializedSearch in the complete widget. The next deep dive on Power Pages React URL parameters and browser history shows how this contract is used with landing-page navigation, pushState, replaceState and popstate.
The service adds the backend operation and owns the business-oriented methods for search and suggestions.
The repository passes the completed request to ISearchTransport. It is deliberately thin in this project. It defines the data-source boundary for the real Power Pages transport and a local mock.
The wiring therefore remains small:
function buildController(transport: ISearchTransport): SearchController {
const repository = new SearchRepository(transport);
const service = new SearchService(repository);
return new SearchController(service);
}A thin repository is not automatically useful. If an application has only one data source, needs no mock and requires no separate data-access boundary, this layer may be unnecessary.
Here, it supports two runtime environments with the same controller. In return, the central host pattern for the React widget keeps these application layers out of page templates.
How does a Power Pages transport encapsulate the runtime?
Only PowerPagesSearchTransport knows window.webapi, safeAjax and the Server Logic path.
export class PowerPagesSearchTransport implements ISearchTransport {
async submitSearchFilters(request: SearchServerLogicRequest): Promise<SearchApiData<SearchResponse>> {
const rawData = await postServerLogicRequest(request);
return parseSearchResponse(rawData);
}
async fetchSuggestions(request: SuggestServerLogicRequest): Promise<readonly SearchSuggestion[]> {
const rawData = await postServerLogicRequest(request);
return parseSuggestionResponse(rawData);
}
}Repository, service and controller therefore need no knowledge of callback structures. They work only with Promises.
The adapter has two responsibilities:
- It translates the Promise contract into the Power Pages AJAX call.
- It passes received strings to the appropriate runtime parser.
CSRF protection, server-side authorization and OData construction remain separate responsibilities. The previous article on the Power Pages Server Logic security boundary documents those checks.
Which JSON contracts does a Power Pages response have?
Processing the response is not finished after a single JSON.parse.
The Microsoft documentation on calling Server Logic shows the outer Power Pages envelope with request ID, success status, data and Server Logic name.
The wrapper used in this project provides that information in the callback. Its data field contains the serialized return value of my Server Logic.
This inner string has its own operation contract:
{
"operation": "search",
"data": {
"IsSucceeded": true,
"Content": "{\"lcid\":1031,\"objects\":[]}"
},
"serverLogicName": "catalog-search"
}The Content field is another JSON string.
This example shows the Search operation. Its data contains the Custom API envelope, and Content contains serialized JSON again.
Suggest and the detail batch have a different third validation level. Suggest returns suggestions directly. The detail batch directly returns entityType, results, notFoundIds and invalidIds.
The shared operation parser confirms only the envelope, discriminator and data object. An operation-specific parser then takes over.
I therefore treat three boundaries separately:
Validation level | Confirms | Typical check |
|---|---|---|
Power Pages envelope | HTTP and Server Logic calls succeeded | Check success status and pass |
Operation contract | Response belongs to | Discriminator and presence of a data object |
Operation data | Search envelope, suggestion list or detail batch | Operation-specific fields and result lists |
None of these boundaries may be skipped through a TypeScript cast.
How does an operation parser prevent mismatched Power Pages responses?
The shared parser receives the expected discriminator as an argument:
type ServerLogicOperation = 'search' | 'suggest' | 'getPortalCatalogDetails';
function parseJson(value: string, label: string): unknown {
try {
return JSON.parse(value) as unknown;
} catch {
throw new Error(`${label} is not valid JSON.`);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseOperationResult(
value: string,
expectedOperation: ServerLogicOperation
): {
readonly data: Record<string, unknown>;
readonly serverLogicName: string;
} {
const parsed: unknown = parseJson(value, 'Server Logic data');
if (!isRecord(parsed)) {
throw new Error('Server Logic data must be an object.');
}
if (parsed['operation'] !== expectedOperation) {
throw new Error(`Server Logic operation must be "${expectedOperation}".`);
}
if (!isRecord(parsed['data'])) {
throw new Error('Server Logic data does not contain a data object.');
}
return {
data: parsed['data'],
serverLogicName: typeof parsed['serverLogicName'] === 'string' ? parsed['serverLogicName'] : 'catalog-search',
};
}A Suggest result therefore cannot accidentally be processed as a complete search response. The same applies to a detail batch. The discriminator must match the calling parser.
Invalid JSON, a missing data object and an incorrect operation each produce an explicit error. The parser invents no replacement value.
How deeply must Power Pages results be validated?
The search parser first checks the Custom API envelope.
IsSucceeded must be a Boolean. Content must be a string. An optional OData context must also be a string.
Next, Content is parsed again. The search parser checks required top-level fields and whether the expected result lists are arrays.
This does not fully validate the elements of those lists.
That distinction matters. A TypeScript array cast after Array.isArray does not automatically validate its elements. If a consumer uses an individual result field for a business-critical purpose, that field needs additional runtime validation or an appropriate schema.
Detail DTOs also have TypeScript contracts. A browser consumer initially treats received center or area elements as unknown. Runtime validation must cover the fields accessed by the consumer. This applies whether the data comes from single retrieval or a favourites batch.
The suggestion contract is smaller. There, the parser checks every entry:
function isSuggestionKind(value: unknown): value is SearchSuggestionKind {
return value === 'city' || value === 'postalCode' || value === 'region' || value === 'center';
}
function parseSuggestion(value: unknown, index: number): SearchSuggestion {
if (!isRecord(value)) {
throw new Error(`Suggestion at index ${String(index)} must be an object.`);
}
if (!isSuggestionKind(value['kind'])) {
throw new Error(`Suggestion at index ${String(index)} has an invalid kind.`);
}
if (typeof value['label'] !== 'string' || value['label'].trim() === '') {
throw new Error(`Suggestion at index ${String(index)} needs a label.`);
}
if (typeof value['value'] !== 'string' || value['value'].trim() === '') {
throw new Error(`Suggestion at index ${String(index)} needs a value.`);
}
if (typeof value['isDirectMatch'] !== 'boolean') {
throw new Error(`Suggestion at index ${String(index)} needs isDirectMatch.`);
}
return {
kind: value['kind'],
label: value['label'],
value: value['value'],
isDirectMatch: value['isDirectMatch'],
};
}Validation depth follows the contract. A general claim of complete typing cannot replace it.
How do parser errors remain visible in React and Power Pages?
The transport does not convert a parser error into an empty result.
The rejected Promise passes back through repository and service to the widget. The interface then receives a technical error state.
A successful empty search remains separate:
type SearchExecutionState =
| { readonly status: 'success'; readonly response: SearchResponse }
| { readonly status: 'error'; readonly message: string };This example type summarizes the visible decision. In the real widget, the state is controlled through Promise success and error handling.
The boundary prevents a dangerous impression. No matches do not automatically mean that Server Logic, Custom API and parser worked correctly.
How do validated search results become a React consumer contract?
After successful validation, the widget has a typed result contract:
export type SearchResultsEventDetail = SearchApiData<SearchResponse>;
export function dispatchSearchResults(eventName: string, response: SearchResultsEventDetail): void {
window.dispatchEvent(
new CustomEvent(eventName, {
detail: response,
})
);
}The widget publishes only responses that passed the preceding parser.
A Vanilla JavaScript consumer receives the same object at runtime as a React consumer. TypeScript supports only consumers that import the type contract. Other consumers need the documented event shape as their own contract.
The event handoff is therefore not a new validation boundary. It distributes an already accepted result.
The following article on Power Pages React CustomEvents shows when the producer publishes this contract and how independent consumers subscribe to it.
When do typed React and Dataverse interfaces justify the effort?
This architecture would be too large for a single simple Web API request.
My search combines several requirements:
- The shared POST endpoint has three separate request and result contracts.
- Complete search and auto-suggest have different contracts.
- Landing page and results page use the same controller in different flows.
- Local development and Power Pages use different transport implementations.
- The Custom API returns nested serialized responses.
- Several consumers should receive validated results.
Under these conditions, the layers provide verifiable boundaries. They do more than distribute code across additional files.
The general article on React frontend architecture in Power Pages helps determine when an isolated widget makes sense and when native Power Pages features are sufficient.
Typed React and Dataverse interfaces in the enterprise project
I implemented this structure for a complex enterprise search.
The visible React part can therefore work with small methods. The Power Pages runtime stays in the adapter. Server Logic and Custom API responses are checked step by step. Decoupled consumers receive only the accepted result contract.
The ECE Power Pages enterprise search case study shows the project context. The code excerpts in this article are generalized.
I am not selling a universal repository template. I am documenting how I make integration boundaries understandable for a business-critical Power Pages workflow.
Are you planning a React application in Power Pages that uses Server Logic or a Dataverse Custom API? In the Power Pages Pro-Code section, I explain how I clarify contracts, runtime boundaries and handoff risks before development.