Skip to content
rocambille edited this page Aug 17, 2026 · 22 revisions

Summary: Typing consistency is essential to ensure code robustness and reliability. StartER relies on TypeScript to ensure this consistency end-to-end: from client (React) to server (Express). This page introduces two fundamental typing techniques used in the framework.

What you'll learn:

  • Declare shared types in the global types/ directory
  • Understand how RowId handles SQLite's bigint behavior
  • Use declare global to extend Express request types

The role of TypeScript

StartER uses TypeScript to guarantee a strict, shared contract between your database, API, and components.

Implementation in StartER

Shared ambient types: src/types/index.d.ts

The src/types/index.d.ts ambient file re-exports resource types directly from their Single Source of Truth Zod schema files (*Schemas.ts). This makes entity types accessible globally across both backend and frontend without explicit imports or zod dependencies in ambient declarations.

// src/types/index.d.ts
type Item = import("../express/modules/item/itemSchemas").Item;
type User = import("../express/modules/user/userSchemas").User;

In src/express/modules/item/itemSchemas.ts, the type is inferred directly from the master Zod schema:

export const ItemSchema = z.object({
  id: z.number(),
  title: z.string().max(255),
  user_id: z.number(),
});

export type Item = z.infer<typeof ItemSchema>;

Types defined in src/types/index.d.ts are automatically available throughout the application.

In React components, these types are used directly. For example, in src/react/components/item/ItemList.tsx:

const items = use(getOrFetch<Item[]>("/api/items"));

Here, Item[] indicates that the items variable contains an array of objects conforming to the Item type.

Tip

The simple signature getOrFetch<Item[]>("/api/items") is shown here for type inference illustration. For paginated resources, see Pagination for the options-based getOrFetch signature.

Similarly, in Express modules, repositories import master entity schemas to parse database rows safely. For example, in src/express/modules/item/itemRepository.ts:

import { ItemSchema } from "./itemSchemas";

// ...

findAll(limit: number, offset: number): Item[] {
  // ...

  return rows.map((row) => ItemSchema.parse(row));
}

The same Item type is shared across Express and React. Parsing SQL rows through ItemSchema.parse(row) guarantees runtime type safety.

Declaration merging for Express Request interface

Interfaces in TypeScript are open. If an interface is defined with the name of an existing interface, its properties are added to the original. This capability, called declaration merging, allows properties to be added to an existing interface without redefining it entirely. StartER leverages this technique to enrich Express Request interface with module-specific data.

Every Express module that adds a property to the Request object does so via a declare global block. For example, in src/express/modules/item/itemParamConverter.ts:

declare global {
  namespace Express {
    interface Request {
      item: Item;
    }
  }
}

After this declaration, TypeScript recognizes req.item as a valid property of type Item on all Express Request objects in the application.

req.item = item; // OK: req.item exists

Thus, each module extends Request with what it needs, with a cascading effect on the rest of the project. The TypeScript IDE and compiler recognize the added properties as legitimate.

Best practices and use cases

  1. Keep common types in index.d.ts: these are the ones shared between Express and React. This avoids duplication and ensures that both frontend and backend share the same data contract.
  2. One declaration = one responsibility: each module must extend Request only for its needs. If a module extends Request with properties it doesn't own, types become unpredictable for other modules.
  3. Name properties consistently (like req.item, req.user...): naming conventions make code easier to read and reduce typos (reliable autocomplete).
  4. Avoid name collisions between modules: two modules using the same property name on Request would produce a silent type conflict.
  5. Document why a property is added: the declare global block is invisible in the file that consumes it, so a comment explaining the need helps the next contributor (often yourself).
  6. Type-check component form schemas with ambient types: annotate component Zod schemas with z.ZodType<Pick<Entity, "field">> to ensure frontend form schemas catch entity property changes at compile-time without importing backend runtime code.

See also

Clone this wiki locally