TypeScript
WebJs is built for TypeScript from the ground up, but never forces a build step you run. It runs on Node 24+ or Bun; on Node the type-stripping is the built-in (process.features.typescript === 'strip'), on Bun it is amaro (byte-identical). Server-side .ts imports work without any loader registration; browser-bound .ts requests go through the runtime stripper on the dev server. Both paths perform whitespace replacement: every (line, column) in the source maps to the same position in the stripped output, so no sourcemap is shipped and stack traces are byte-exact.
No-Build TypeScript
The runtime's type stripping does the heavy lifting: Node 24+'s built-in module.stripTypeScriptTypes, or amaro on Bun (the same engine Node wraps internally, so the output is byte-identical). The dev server reads each .ts request from disk, strips it, and serves the result. Transform time is around 1ms per file; the result is cached by mtime, so subsequent loads are instant. SSR and hydration produce identical JS because both halves use the same stripper.
On the server side, your pages, layouts, server actions, and middleware run as-is. On the client side, browsers fetch .ts URLs and receive whitespace-stripped JS with no sourcemap appended. The URL keeps its .ts extension; only the response body changes. In production, webjs start uses the same code path, with the same mtime cache.
The "no build" promise is literal: every position in source maps to itself in runtime. DevTools shows accurate stack traces without consulting a sourcemap, and wire bytes drop by roughly 70% vs a bundler-with-sourcemap pipeline.
Use .ts or .js: both are first-class
WebJs treats .ts, .mts, .js, and .mjs identically for routing and module resolution. The router recognises page.ts and page.js the same way. The action scanner recognises create-post.server.ts and create-post.server.js. Pick your preference and be consistent, or mix them freely across your project.
tsconfig.json Setup
TypeScript type-checking is entirely optional, but recommended. Here is the recommended tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node"],
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"erasableSyntaxOnly": true,
"plugins": [{ "name": "@webjsdev/intellisense" }]
},
"include": [
"app/**/*",
"components/**/*",
"modules/**/*",
"lib/**/*",
"test/**/*",
"middleware.js",
"middleware.ts",
".webjs/routes.d.ts"
],
"exclude": ["node_modules", ".webjs/vendor", "db/migrations"]
} Key settings explained:
- erasableSyntaxOnly: true: rejects non-erasable TypeScript syntax (
enum,namespacewith values, constructor parameter properties, legacy decorators,import = require) at compile time. Required because Node's built-in stripper only supports erasable TypeScript. Violations surface as red squiggles in the editor. See TypeScript Feature Support below for the erasable equivalents. - noEmit: true: WebJs never compiles TypeScript to JavaScript on disk. The TypeScript compiler is used only for type-checking (
tsc --noEmit). Node runs your.tsfiles directly via its built-in stripper. - allowImportingTsExtensions: true: lets you write
import { foo } from './bar.ts'with the explicit.tsextension. This is the WebJs convention (see below). - include covers test/:
npm run typecheckreads the tests you write, so a type error there is a failed check rather than something a reviewer has to spot. Note what is NOT here:checkJs, which turns on checking of your.jsfiles too (and impliesallowJs). That is a real option for a JSDoc-typed codebase, covered under JSDoc Alternative below, but it is not the default here, because a browser test written as.jsis served to a real browser and has none of the test-runner globals in scope, so each one would report an unresolved name. - module / moduleResolution: NodeNext: matches how Node resolves ESM imports, including
.tsextensions.
Import Convention: the # root alias and explicit .ts extensions
WebJs apps address top-level folders through the # root alias (#db/..., #lib/..., #modules/..., #components/...) rather than deep ../../ relative paths. It is Node's native package.json "imports" map (the scaffold ships a single "#*": "./*" entry, so every top-level folder is aliased), resolved with no build step and no tsconfig paths on Node 24+ and Bun. The sigil is # with no slash after it (#lib/..., never #/lib/...). A same-directory import stays relative (./sibling.ts).
Always use the real file extension in your imports:
// Good: explicit .ts extension
import { db } from '#db/connection.server.ts';
import { createPost } from '#modules/posts/actions/create-post.server.ts';
import type { PostFormatted } from '#modules/posts/types.ts';
// Also fine: .js files
import { slugify } from '#lib/utils/slugify.js';
// Avoid: extensionless imports don't work with Node's ESM or in browsers
import { db } from '#db/connection.server'; // ERROR This convention works because:
- The runtime strips types from
.tsimports server-side natively (Node 24+, or Bun). No loader hook required. - The dev server reads
.tsfiles from disk, strips them (Node'smodule.stripTypeScriptTypesoramaroon Bun), and serves the result with position-preserving whitespace replacement. - When the browser requests a
.jsfile that doesn't exist but a sibling.tsdoes, WebJs falls back to the.tsversion automatically. This means libraries that import without extensions can still work.
Full-Stack Type Safety
Server actions in WebJs provide end-to-end type safety without code generation. When a client component imports from a .server.ts file, TypeScript sees the real function signature:
// modules/posts/actions/create-post.server.ts
'use server';
export type ActionResult<T> =
| { success: true; data: T }
| { success: false; error: string; status: number };
export interface CreatePostInput { title: string; body: string }
export async function createPost(
input: CreatePostInput
): Promise<ActionResult<PostFormatted>> {
// server-only code: database queries, auth checks, etc.
const me = await currentUser();
if (!me) return { success: false, error: 'Not signed in', status: 401 };
// ...
} // components/new-post-form.ts: client component
import { createPost } from '#modules/posts/actions/create-post.server.ts';
// TypeScript knows createPost accepts (input: CreatePostInput)
// and returns Promise<ActionResult<PostFormatted>>
const result = await createPost({ title, body });
if (result.success) {
// result.data is typed as PostFormatted
console.log(result.data.slug);
} At runtime, the browser never receives the server code. WebJs replaces the import with a thin RPC stub that calls POST /__webjs/action/:hash/createPost. But TypeScript's type checker sees through the .server.ts boundary and validates argument/return types at compile time.
Derive the type, never unknown or any
That guarantee is only worth what your annotations are worth. An action typed input: unknown still runs, still passes tsc, and still passes webjs check (both are valid TypeScript, so this is a convention rather than a correctness rule), but it hands the call site nothing. Every value crossing an app boundary in WebJs already has a type you can reach, so reach for it.
| Boundary | Write this | Not this |
|---|---|---|
| A database row | export type Post = typeof posts.$inferSelect | a hand-written interface that drifts from the schema |
| That row inside a shipping component | import type { Post } from '#db/schema.server.ts' | any[], or re-declaring the shape |
| An action's input | a named interface | input: unknown / input: any |
| An action's result | ActionResult<T> | Promise<any> |
| A page | PageProps<'/blog/[slug]'> | { params: Record<string, any> } |
| A layout | LayoutProps | { children: unknown } |
| A route handler's 2nd argument | RouteHandlerContext | { params: any } |
| A client-router href | the generated Route union | a bare string |
| A reactive property | prop<Student>(Object) | prop(Object) plus a cast at every read |
unknown is the more tempting of the two escape hatches because it reads as the safe one. It is safe only in that it forces a narrow at the read site. As a boundary type it is exactly as uninformative as any, and it pushes a cast into every consumer.
Where unknown IS right, first case: a payload nothing has vouched for yet, narrowed on the very next line. A route.ts handler's await req.json(), an action's export const validate, a catch binding (already unknown under strict). The test is what the next line does.
Second case, and it is NOT narrowed: a parameter of YOUR OWN helper that forwards its argument into an html template hole. A hole renders a string, a number, a TemplateResult, an array of those, a directive result, or nothing, so a helper like lede(content: unknown) is correctly typed and TemplateResult alone would be too narrow. This is about a value you accept, never one the framework hands you: a layout's children also lands in a hole, but LayoutProps already types it as a TemplateResult.
Everywhere else it is a missing type, not a safe one: unknown surviving into a return type, a component prop, a layout's children, or an action signature is the shape to fix. any gets no carve-out at all, since it does not defer checking, it disables it.
Typed metadata and page props
The same isomorphic @webjsdev/core surface a page already imports html from also exports the types for its routing files, so metadata, page / layout / route-handler props, and client-router hrefs all type-check. Every one is a pure type (zero runtime, erased at strip time, no build cost).
Page metadata: Metadata and generateMetadata
A page or layout exports metadata (static) or generateMetadata(ctx) (request-scoped). Annotate the return with the exported Metadata type so a misspelled field (titel, descripton) or a wrong-typed value (themeColor: 123) is a compile-time error, the same ergonomics as Next.js's import type { Metadata } from 'next' (#257). MetadataContext types the generateMetadata argument.
import type { Metadata, MetadataContext } from '@webjsdev/core';
export const metadata: Metadata = {
title: 'Blog',
description: 'Latest posts',
openGraph: { type: 'website', image: '/og.png' },
twitter: { card: 'summary_large_image' },
};
export async function generateMetadata(ctx: MetadataContext): Promise<Metadata> {
return { title: `Post: ${ctx.params.slug}`, metadataBase: new URL(ctx.url).origin };
} Metadata covers every field the SSR pipeline reads. Each field is optional, and string-or-object fields (title, viewport, robots, appleWebApp, icons) are unions, so both forms type-check. MetadataContext is { params, searchParams, url, actionData } (where actionData is set only on a failed form-action re-render). See Metadata Routes for the full field reference.
Page / layout / route-handler props (PageProps, LayoutProps, RouteHandlerContext)
A page default export receives { params, searchParams, url, actionData }; a layout receives the same plus children; a route.ts handler receives (request, { params }). Type each with the exported helpers so a typo in a param name or a wrong-typed field is a compile-time error (#258).
import type { PageProps, LayoutProps, RouteHandlerContext } from '@webjsdev/core';
// A static route: params is Record<string, string>.
export default function About({ searchParams }: PageProps) { /* ... */ }
// A dynamic route: pass the route literal to narrow params.
export default function Post({ params }: PageProps<'/blog/[slug]'>) {
const slug = params.slug; // typed string
/* ... */
}
// A layout adds children: TemplateResult.
export default function RootLayout({ children }: LayoutProps) { /* ... */ }
// A route handler's 2nd arg.
export async function GET(req: Request, ctx: RouteHandlerContext) {
return Response.json({ id: ctx.params.id });
} PageProps<R> / LayoutProps<R> / RouteHandlerContext<R> take an optional route literal R. With no R (or in an app that has not generated route types), params is Record<string, string>, the runtime default. With R set to a generated dynamic route, params narrows to its exact shape ({ slug: string }, { rest: string[] }, { slug?: string[] }). The shapes mirror what the server actually passes, NOT Next.js's superset.
The generated route union (webjs types)
Run webjs types to generate .webjs/routes.d.ts, an opt-in overlay that augments @webjsdev/core with one key per route in app/. It narrows two things at tsserver time.
- The
Routehref type thatnavigate()and a typed<a href>accept:navigate('/blog/anything')is accepted,navigate('/nonexistent')is a type error. Until you generate the types,Routeisstring, sonavigate()is unconstrained, non-breaking for JSDoc apps and un-generated apps alike. - Per-route
params:PageProps<'/blog/[slug]'>['params']becomes{ slug: string }, derived from the generatedRouteParamMap.
webjs types # writes .webjs/routes.d.ts (count of routes printed) webjs dev also emits it automatically at startup and re-emits after each route rebuild, so an editor always has fresh route types. The file is gitignored (regenerated per machine, like Next's .next/types); the scaffold tsconfig.json lists .webjs/routes.d.ts in include so tsserver picks it up. To opt in for an existing app, run webjs types once and ensure your tsconfig.json include lists .webjs/routes.d.ts. This is webjs's no-build equivalent of Next 15's typedRoutes, achieved via interface declaration-merging rather than a bundler. Output is deterministic (sorted keys), so re-running yields a byte-identical file.
Rich Types Across the Wire
Standard JSON cannot represent Date, Map, Set, BigInt, undefined, NaN, Infinity, TypedArray, Blob, File, or FormData. WebJs ships its own pure-ESM serializer (in @webjsdev/core) used for all server action RPC calls and for the json() / richFetch() helpers, so rich types survive the network round-trip, including binary content (file uploads through actions just work).
// Server action
export async function getEvents(): Promise<Event[]> {
return db.query.events.findMany(); // createdAt is a Date
}
// Client: createdAt arrives as a real Date, not a string
const events = await getEvents();
events[0].createdAt instanceof Date; // true
events[0].createdAt.toLocaleDateString(); // works For API routes, the same content negotiation applies. Use json() from @webjsdev/server on the server side and richFetch() from webjs on the client side to get rich-type encoding. External consumers (curl, other services) get plain JSON automatically.
Typing the auth() Session User
auth() resolves { user }, and by default user is Record<string, unknown>, so reading a custom field your session/jwt callbacks set (such as session.user.id) needs a cast and a typo is not caught. Two opt-in, types-only ways make user typed.
Augment the AuthUser interface (NextAuth/Auth.js style) to type every auth() call across the app:
declare module '@webjsdev/server' {
interface AuthUser {
id: string;
role: 'admin' | 'member';
}
} Or parameterise the factory to type one instance without a global augmentation: createAuth<AppUser>(...) returns an auth() whose user is AppUser:
const { auth } = createAuth<AppUser>({ secret, providers });
const session = await auth();
session?.user.role; // typed, no cast Un-augmented and un-parameterised, AuthUser is empty and resolves back to Record<string, unknown>, so pre-existing untyped code keeps compiling. The declared fields should mirror what the callbacks write onto session.user. See Auth Providers (createAuth).
JSDoc Alternative
If you prefer .js files, you can achieve the same type safety using JSDoc annotations with checkJs: true in your tsconfig:
// db/connection.server.js
import * as schema from './schema.server.js';
const { DatabaseSync } = await import('node:sqlite');
const { drizzle } = await import('drizzle-orm/node-sqlite');
export const db = drizzle({ client: new DatabaseSync('db/dev.db'), relations: schema.relations });
/**
* @param {{ title: string, body: string }} input
* @returns {Promise<{ success: boolean, data?: Post, error?: string }>}
*/
export async function createPost(input) {
// TypeScript checks types via JSDoc, same strictness
} You can also define complex types with @typedef:
/**
* @typedef {{
* id: number,
* title: string,
* slug: string,
* body: string,
* createdAt: Date,
* author: { name: string, email: string }
* }} PostFormatted
*/
/** @param {PostFormatted} post */
export function formatDate(post) {
return post.createdAt.toLocaleDateString();
} JSDoc-typed .js files and .ts files can import each other freely. The type checker treats them as part of the same project.
TypeScript Feature Support: Erasable Only
WebJs uses Node 24+'s built-in module.stripTypeScriptTypes as its primary stripper. That stripper only supports erasable TypeScript: type annotations, interface, type, declare, generics, import type, as casts, satisfies. Non-erasable syntax is rejected at compile time (via erasableSyntaxOnly: true) and at runtime.
What's not allowed, and what to write instead:
- Enums are not allowed. Use a
constobject plus a derived union type. - Namespaces with values are not allowed. Use a plain object or an ES module.
- Constructor parameter properties are not allowed. Declare the field explicitly and assign in the constructor body.
- Legacy decorators with
emitDecoratorMetadataare not allowed. Stage-3 standard decorators work fine. import = require()is not allowed. Use standard ES import.- Generics, type aliases, interfaces, type assertions, satisfies, const assertions: all supported (these are erasable).
// Not allowed (red squiggle with erasableSyntaxOnly):
enum Direction { Up, Down, Left, Right }
class User { constructor(public name: string) {} }
namespace Util { export const VERSION = '1.0'; }
// Erasable equivalents:
const Direction = { Up: 'up', Down: 'down', Left: 'left', Right: 'right' } as const;
type Direction = (typeof Direction)[keyof typeof Direction];
class User {
name: string;
constructor(name: string) {
this.name = name;
}
}
const Util = { VERSION: '1.0' }; This constraint exists because Node's stripper performs whitespace replacement, not AST regeneration. enum requires emitting a real runtime object, which would change line numbers and require shipping a sourcemap. Banning it preserves the byte-exact position property.
WebJs is buildless end-to-end: there is no bundler fallback for non-erasable syntax. If a .ts file uses enum, namespace with values, parameter properties, legacy decorators with emitDecoratorMetadata, or import = require, Node's stripper throws and the dev server returns a 500 naming the file and pointing at the no-non-erasable-typescript lint rule. The companion erasable-typescript-only rule additionally warns when erasableSyntaxOnly is missing or off in tsconfig.json, so the compiler catches the same constructs at edit time. The realistic edge case (a third-party package shipping raw non-erasable .ts) is rare in practice: published packages compile to .js with sidecar .d.ts files, which the runtime serves as plain JavaScript with no transform.
Mixed Codebases
.js and .ts files can coexist in the same WebJs project and import each other without restriction:
my-app/
app/
layout.ts # TypeScript
page.js # JavaScript
blog/
page.ts # TypeScript
[slug]/
page.ts
components/
counter.ts # TypeScript component
footer.js # JavaScript component
db/
connection.server.ts
lib/
utils.js # JSDoc-typed JavaScript
middleware.ts # TypeScript
tsconfig.json // app/page.js can import from .ts files
import '#components/counter.ts';
// lib/utils.js can import from .ts files
import { db } from '#db/connection.server.ts';
// app/blog/page.ts can import from .js files
import '#components/footer.js'; The router, action scanner, dev server, and production bundler all accept .ts, .mts, .js, and .mjs interchangeably. Type-check the whole project with a single tsc --noEmit.
Running Type Checks
WebJs does not type-check at runtime or during dev serving. Add a type-check command to your workflow:
{
"scripts": {
"dev": "webjs dev",
"start": "webjs start",
"typecheck": "webjs typecheck"
}
} webjs typecheck is the project's own tsc --noEmit, and it is what a scaffolded app ships. Run npm run typecheck in CI or as a pre-commit hook. The dev server stays fast because it only strips types. Full type analysis is a separate, parallelizable step.
It reads whatever the include above names, which covers test/ as well as your app source. A type error in a test is a failed check rather than something a reviewer has to catch by eye.