Skip to content

Break user code sdk code cyclic dep demo 3#3871

Draft
FranjoMindek wants to merge 63 commits intomainfrom
franjo/virtual-files-2nd-approach
Draft

Break user code sdk code cyclic dep demo 3#3871
FranjoMindek wants to merge 63 commits intomainfrom
franjo/virtual-files-2nd-approach

Conversation

@FranjoMindek
Copy link
Contributor

@FranjoMindek FranjoMindek commented Mar 5, 2026

Description

Questions:

  • How do we handle user virtual modules plugin?
    • 2 separate plugins (client vite, server rollup) vs 1 rollup plugin (sdk). The plugin has to receive the path as a parameter (since they have different cwd locatiosn).
    • Where do we locate the server plugin? server/src/plugins/?

Type of change

  • 🔧 Just code/docs improvement
  • 🐞 Bug fix
  • 🚀 New/improved feature
  • 💥 Breaking change

Checklist

  • I tested my change in a Wasp app to verify that it works as intended.

  • 🧪 Tests and apps:

    • I added unit tests for my change.
    • (if you fixed a bug) I added a regression test for the bug I fixed.
    • (if you added/updated a feature) I added/updated e2e tests in examples/kitchen-sink/e2e-tests.
    • (if you added/updated a feature) I updated the starter templates in waspc/data/Cli/templates, as needed.
    • (if you added/updated a feature) I updated the example apps in examples/, as needed.
      • (if you updated examples/tutorials) I updated the tutorial in the docs (and vice versa).
  • 📜 Documentation:

    • (if you added/updated a feature) I added/updated the documentation in web/docs/.
  • 🆕 Changelog: (if change is more than just code/docs improvement)

    • I updated waspc/ChangeLog.md with a user-friendly description of the change.
    • (if you did a breaking change) I added a step to the current migration guide in web/docs/migration-guides/.
    • I bumped the version in waspc/waspc.cabal to reflect the changes I introduced.

@FranjoMindek
Copy link
Contributor Author

Lazy Types with Declaration Merging

How we fetch user types

Wasp uses TypeScript module augmentation to bring user-defined types into the SDK. Empty registry interfaces are declared in wasp/types, and generated declare module blocks merge user types into them at build time:

// SDK: empty registry
export interface CrudOverridesRegistry {}

// Generated augmentation (from user's code):
declare module 'wasp/types' {
  interface CrudOverridesRegistry {
    'tasks': {
      GetAll: typeof import('./tasks').getAllOverride
    }
  }
}

Conditional types resolve the override or fall back to a default:

type GetAllQueryResolved = FromCrudOverridesRegistry<'tasks', 'GetAll', DefaultGetAllQuery>
// → user's override if augmented, DefaultGetAllQuery otherwise

Why types must stay lazy

The SDK is compiled with tsc to produce .d.ts declaration files, which consumer code (user's app) then imports. TypeScript's declaration emitter handles inferred vs annotated types differently:

With explicit annotation — the emitted .d.ts preserves the type alias:

// Source
const _getAllQuery: QueryFor<GetAllQueryResolved> = createQuery<GetAllQueryResolved>(...)

// Emitted .d.ts
declare const _getAllQuery: QueryFor<GetAllQueryResolved>;

The consumer resolves GetAllQueryResolved against the augmented CrudOverridesRegistry, picking up the user's override.

Without annotation — the emitter expands the type into its full structural form:

// Source
const _getAllQuery = createQuery<GetAllQueryResolved>(...)

// Emitted .d.ts (conditionals already resolved to defaults)
declare const _getAllQuery: {
  (args: {}): Promise<{ id: number; description: string; userId: number }[]>;
  queryCacheKey: string[];
};

The declaration emitter asks the type checker for the fully inferred type of the variable. Since the variable is unannotated, the checker evaluates the conditional using only the types visible during SDK compilation. The override is lost — the consumer sees only the default Prisma types.

Note: source type-checking (tsc --noEmit) works correctly in both cases — the issue is specifically in declaration emit. The SDK builds first, then the consumer imports the emitted .d.ts files. This two-phase compilation is why the timing of type resolution matters.

Two approaches to lazy types

Generic function defaults (TanStack Router's approach):

function useLoaderData<TRouter = RegisteredRouter>() { ... }

Generics must remain abstract in emitted declarations, so the emitter preserves them rather than resolving them. Each call site resolves the default against its own augmentation context. This pattern works naturally for APIs that resolve types at function call sites, but not for stored values.

Explicit type annotations (Wasp's approach):

Wasp exposes value-based APIs — env.MY_VAR, tasks.get.query, dbClient — where types must be stored in variables. Explicit annotations force the declaration emitter to preserve the type alias references rather than expanding them:

const _getQuery: QueryFor<GetQueryResolved> = createQuery<GetQueryResolved>(...)
export const env: ClientEnv = _env
export const dbClient: UserPrismaClient = setupFn()

Even when the annotation appears redundant (e.g., createQuery<T> returns QueryFor<T>), removing it causes the declaration emitter to expand the type, breaking override resolution for consumers.

Since all annotations live in generated template code, this cost is invisible to users.

# Conflicts:
#	waspc/data/Generator/templates/sdk/wasp/client/env.ts
#	waspc/data/Generator/templates/sdk/wasp/client/env/schema.ts
#	waspc/data/Generator/templates/sdk/wasp/client/vite/plugins/validateEnv.ts
#	waspc/data/Generator/templates/sdk/wasp/server/env.ts
#	waspc/src/Wasp/Generator/Valid/TsConfig.hs
#	waspc/src/Wasp/Util/StrongPath.hs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants