Skip to content

Feature: Strongly Typed useViewModel with specification() Helper #18

@michaellperry

Description

@michaellperry

Goals

This change improves the developer experience of defining view models so that it naturally supports expected application behavior:

  • Atomically load all data projections: The data field remains null until all specifications have been resolved. Once available, data contains fully-resolved, strongly typed, and non-null inner values.
  • Clearly represent loading state: The loading flag indicates whether data is still being fetched from a remote replicator. If all required data is already available in local storage, loading is false, and data is ready immediately.
  • Track background command activity: The busy flag reflects whether any of the command functions are currently in progress, so developers can disable buttons or show spinners accordingly.
  • Surface errors in one place: The error field combines both command errors and errors that occur while loading from the remote replicator. This simplifies error handling in the view.
  • Simplify view model declaration: Developers can use the specification() helper function to declare each data requirement in a concise, strongly typed way without having to manage type annotations manually.

This helps application developers write views that are easy to reason about, avoid rendering partial data, and provide a seamless experience across online and offline modes.


Background

The useSpecification hook already provides a clear mechanism for loading data from a specification with typed results. We want to build on that with a new useViewModel hook. This combines specifications and commands to make the experience more ergonomic and type-safe for application developers.


Type Declarations

import { SpecificationOf } from 'jinaga';

type NullableElements<T extends readonly unknown[]> =
  T extends [infer First, ...infer Rest]
    ? [First | null, ...NullableElements<Rest>]
    : [];

export interface SpecificationInput<TGiven extends readonly unknown[], TProjection> {
  specification: SpecificationOf<TGiven, TProjection>;
  given: NullableElements<TGiven>;
}

export function specification<TGiven extends readonly unknown[], TProjection>(
  spec: SpecificationOf<TGiven, TProjection>,
  ...given: NullableElements<TGiven>
): SpecificationInput<TGiven, TProjection> {
  return { specification: spec, given };
}

type Command = () => Promise<void>;

type ViewModelInput = {
  data?: Record<string, SpecificationInput<readonly unknown[], any>>;
  commands?: Record<string, Command>;
};

type NonNullableDeep<T> = {
  [K in keyof T]: T[K] extends object
    ? NonNullableDeep<NonNullable<T[K]>>
    : NonNullable<T[K]>;
};

type ProjectionFromSpecInput<I> =
  I extends SpecificationInput<readonly unknown[], infer TProjection>
    ? NonNullableDeep<TProjection>[]
    : never;

type DataOutput<T extends Record<string, SpecificationInput<readonly unknown[], any>>> = {
  [K in keyof T]: ProjectionFromSpecInput<T[K]>;
};

type ViewModelOutput<I extends ViewModelInput> = {
  data: I['data'] extends infer D
    ? D extends Record<string, SpecificationInput<readonly unknown[], any>>
      ? DataOutput<D> | null
      : null
    : null;
  commands: I['commands'] extends Record<string, Command> ? I['commands'] : {};
  error: Error | null;
  loading: boolean;
  busy: boolean;
};

declare function useViewModel<I extends ViewModelInput>(
  input: I
): ViewModelOutput<I>;

Example Usage

const viewModel = useViewModel({
  data: {
    projects: specification(projectsOfUser, currentUser),
    tasks: specification(tasksOfProject, selectedProject)
  },
  commands: {
    assignTask: async () => { /* ... */ }
  }
});

// viewModel.data?.projects[0].title  // ✅ Fully typed, non-null
// viewModel.data?.tasks[0].dueDate   // ✅ Typed deeply, never undefined/null

Acceptance Criteria

  • The useViewModel hook returns a fully inferred and strongly typed data object.
  • The data field is null while loading or in error, but otherwise contains only fully-resolved values (no undefined or null subfields).
  • The specification() helper provides a clean way to construct SpecificationInput objects with proper inference of both TGiven and TProjection.
  • No use of any in the final types exposed to consumers.
  • The API remains backward-compatible with existing use cases or a migration guide is included.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions