指南 15
TypeScript
本页尚未翻译,因此显示的是英文原文。 在 GitHub 上翻译
本页内容
tug gen writes the TypeScript of an app's Go types: the props each page
takes, the props every page shares, and the named routes. The TypeScript
compiler then checks a page component against the handler that renders it,
as Go's compiler checks the handler.
Two files, in resources/js/tug:
pages.ts: an interface for each Go struct the props use,SharedProps,Pages, andPageProps<'Component'>, which is a page's own props and the shared ones.routes.ts: the named routes, the values each one's path needs, androute(), which builds a route's path asApp.URLdoes in Go.
Both start with the line
// Code generated by tug gen from the Go types. DO NOT EDIT.: tug writes
them again whenever the Go changes, and an edit made by hand is lost at
the next write.
A page and two routes, in Go:
type Post struct {
ID int64 `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Tags []string `json:"tags"`
}
type PostsShowProps struct {
Post Post `json:"post"`
}
var PostsShow = tug.Page[PostsShowProps]("Posts/Show")
// in main, with the other routes
app.Get("/posts/{id}", show).Name("posts.show")
app.Get("/posts/{id}/edit", edit).Name("posts.edit")pages.ts gets Post and PostsShowProps as interfaces, and
'Posts/Show': PostsShowProps in Pages. routes.ts gets 'posts.edit',
whose path needs { id: string | number }. The component takes them:
import { Link } from '@inertiajs/react'
import type { PageProps } from '../../tug/pages'
import { route } from '../../tug/routes'
export default function Show({ post, appName }: PageProps<'Posts/Show'>) {
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<Link href={route('posts.edit', { id: post.id })}>Edit</Link>
<footer>{appName}</footer>
</article>
)
}appName is a shared prop, from pages.Share("appName", "blog"). Each of
these doesn't compile:
post.titel: "Property 'titel' does not exist on type 'Post'. Did you mean 'title'?"PageProps<'Posts/Shwo'>: notug.Pagedeclared that component, so it isn't inkeyof Pages.route('post.edit', { id: post.id }): no route has that name.route('posts.edit'): "Expected 2 arguments, but got 1." The path needs its{id}; given{}, the error names the missingid.route('posts.edit', { slug: 'hello' }): the path has no{slug}. A value for a wildcard the path doesn't have is refused even next to the ones it needs, and a path with no wildcards takes no values at all.
When a Go field is renamed, removed or given another type, the next
tug gen changes its interface, and every component that uses the old
field stops compiling. tug build type-checks before it builds, with the
app's typecheck script (tsc --noEmit in React's starters, vue-tsc in
Vue's and svelte-check in Svelte's); Vite's build alone doesn't check
types.
The types are the same for every frontend: pages.ts extends
@inertiajs/core, which Inertia's Vue and Svelte adapters read as React's
does. A Svelte page takes its props as a React one does:
<script lang="ts">
import { Link } from '@inertiajs/svelte'
import type { PageProps } from '../../tug/pages'
import { route } from '../../tug/routes'
let { post, appName }: PageProps<'Posts/Show'> = $props()
</script>
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<Link href={route('posts.edit', { id: post.id })}>Edit</Link>
<footer>{appName}</footer>
</article>A Vue page spells its props out:
<script setup lang="ts">
import { Link } from '@inertiajs/vue3'
import type { Pages, SharedProps } from '../../tug/pages'
import { route } from '../../tug/routes'
defineProps<Pages['Posts/Show'] & SharedProps>()
</script>
<template>
<article>
<h1>{{ post.title }}</h1>
<p>{{ post.body }}</p>
<Link :href="route('posts.edit', { id: post.id })">Edit</Link>
<footer>{{ appName }}</footer>
</article>
</template>Vue's compiler makes the component's props, as they are when it runs, from
the type defineProps is given, and it can't work out PageProps<'...'>,
whose Pages[C] has a type parameter for a key: the page type-checks, and
then doesn't build. Pages['Posts/Show'] & SharedProps is the same props,
which it can.
vue-tsc and svelte-check are built on TypeScript's compiler API, as is Vue's compiler when it reads a type from another file. TypeScript 7, which is tsc rewritten in Go, has none yet, so the Vue and Svelte starters have TypeScript 6, where React's have 7.
tug gen builds the app into .tug/app and runs it with TUG_GEN set to
a file. App.Run looks for TUG_GEN before anything else. When it's set,
Run writes the app's TypeScript to that file and returns without
listening. tug gen then writes the two files, each only when it has
changed, so that an unchanged file doesn't set off Vite or an editor. It
says which it wrote, as wrote resources/js/tug/pages.ts, or "the types
are up to date".
tug gen runs the app rather than read its source because route groups and
their prefixes only exist once the app runs. So the TypeScript is of what
main has set up by the time it calls Run:
- Pages: the ones
tug.Pagedeclared. It records each component and its props type when it's called, which for a package-levelvaris beforemainstarts.Config.ErrorPageis there too, withErrorPageProps:{ status: number; message: string }. A page rendered withc.Inertia("Name", props), and never declared, isn't. - Shared props: the values given to
Share, by their Go type. Props thatShareFuncorinertia.WithPropsadd per request aren't; see Shared props. - Routes: the ones named with
.Name, with their method and whole path, group prefixes included.
The TypeScript is written inside the app, by the tug that its go.mod requires. The CLI builds the app, runs it, and puts the files in place.
Whatever main does before Run, it does for tug gen too. It runs with
the environment tug has, and the app's .env under it, as tug dev reads
it. Both starters stop without an APP_KEY, and tug gen shows why:
tug: the app stopped before writing its types (exit status 1):
2026/09/25 15:56:12 session: APP_KEY isn't set; make one with `tug key`, and set APP_KEY to what it printsThe auth starter's database, and its APP_URL, it leaves alone while
tug.Generating() says tug gen started it, so tug gen needs neither:
what only serving needs, main can leave out the same way. A main that
doesn't call Run writes no types, and tug gen asks "does main call
app.Run?": at once when the app exits, and after 30 seconds when it keeps
running.
The types say what encoding/json writes:
type Timestamps struct {
CreatedAt time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
}
type Post struct {
Timestamps
ID int64 `json:"id"`
Title string `json:"title"`
Draft bool `json:"draft,omitempty"`
Tags []string `json:"tags"`
Author *User `json:"author"`
Meta map[string]any `json:"meta"`
secret string
}
type PostsIndexProps struct {
Posts inertia.ScrollProp[Post] `json:"posts"`
Stats inertia.DeferProp[Stats] `json:"stats"`
Tags inertia.OptionalProp[[]string] `json:"tags"`
}export interface Post {
createdAt: string
updatedAt: string | null
id: number
title: string
draft?: boolean
tags: string[]
author: User | null
meta: Record<string, unknown>
}
export interface PostsIndexProps {
posts: { data: Post[] }
stats?: Stats
tags?: string[]
}| Go | TypeScript |
|---|---|
bool |
boolean |
int, uint8, float64, and the other numbers |
number |
inertia.BigInt |
bigint: the client reads it as a BigInt, past JavaScript's safe range too (pages.md) |
string |
string |
[]byte |
string, the base64 that encoding/json writes; a [N]byte is number[] |
[]T, [N]T |
T[], or (A | B)[] for a union: []*int is (number | null)[] |
map[K]V |
Record<string, V>: a JSON object's keys are strings |
*T |
T | null |
a named struct, Post |
Post, an interface of its own |
a generic struct, List[Post] |
List_Post, an interface of its own |
| an anonymous struct | the object type, on one line: { lat: number; lng: number } |
time.Time |
string |
json.RawMessage, and a type with a MarshalJSON method |
unknown |
a type with a MarshalText method, such as netip.Addr |
string |
an interface, such as any or error |
unknown |
A struct's fields:
- A field is named by its json tag, or by its Go name when it has none.
- Unexported fields, and fields tagged
json:"-", are left out. omitemptyoromitzeromakes the property optional:draft?: boolean.,stringmakes it astring, as encoding/json quotes the value.- An embedded struct with no json name has its fields promoted into the
struct's own, as
Timestampsabove; with a name, it's a property. - A name that isn't a JavaScript identifier is quoted:
'user-count': number.
Package inertia's prop types go out as the value they hold, so their type
is that value's:
| Go | TypeScript |
|---|---|
inertia.DeferProp[T], inertia.OptionalProp[T] |
T, and the property is optional: stats?: Stats |
inertia.LazyProp[T], AlwaysProp[T], MergeProp[T], OnceProp[T] |
T |
inertia.ScrollProp[T] |
{ data: T[] } |
A deferred prop is left out of the first load, and an optional one until a
partial reload asks for it, so a page checks it's there before using it:
{stats && <StatsLine stats={stats} />}. Defer(fn).Merge() and
.Rescue() still make a DeferProp, so they're optional too.
Scroll(fn).Defer() is still a ScrollProp, whose type says nothing of
the Defer, so its property isn't marked optional, though the first load
leaves it out: read it inside <Deferred data="posts">, which shows its
children once the prop is there.
Each named struct is one interface, named after its Go type and declared
once however often it's used. Interfaces are listed by name. When two
packages have types of the same name, the one met first keeps the name and
the other has its package's name in front: Decoder from encoding/json
beside xml_Decoder.
A page's props are its props struct's interface. A page whose props type
isn't a struct, such as tug.Page[tug.Props], has
Record<string, unknown>, and one with struct{} has {}.
SharedProps has a property for each value given to Share:
pages.Share("appName", "blog")export interface SharedProps {
appName: string
}PageProps<C> is Pages[C] & SharedProps. pages.ts also tells
Inertia's own types about them, by adding sharedPageProps: SharedProps
to the InertiaConfig interface of @inertiajs/core. So usePage() knows
the shared props with no type argument, as the starter's Layout.tsx uses
it:
const { props, flash } = usePage()
// props.appName is a string
A page's own props aren't in usePage()'s type: they come to the page
component. The generated interfaces don't fit usePage's type parameter,
which asks for an index signature, so usePage<PageProps<'Posts/Show'>>()
doesn't compile. Pass a child the props it needs.
A prop that ShareFunc works out for each request has no type tug gen can
see. Give Share a first value of the same type, and tug gen learns the
type from it; when a page is rendered, ShareFunc's value takes its place.
The auth starter shares the user this way:
// Auth is the auth prop every page shares: the user who's logged in, or
// null for a guest.
type Auth struct {
User *User `json:"user"`
}
pages.Share("auth", Auth{})
pages.ShareFunc(a.shareAuth) // inertia.Props{"auth": Auth{User: u}}SharedProps gets auth: Auth, and pages.ts an interface
Auth { user: User | null }, so every page has auth.user typed.
For a prop with no first value to give, or one that middleware adds with
inertia.WithProps, add it to SharedProps from a file of your own. The
path in declare module is relative to that file. resources/js/types.ts,
where the starters keep the flash type, will do:
declare module './tug/pages' {
interface SharedProps {
unread: number
}
}The file ends in export {}, which makes it a module: outside one,
TypeScript refuses a relative path in declare module.
Flash data isn't a prop, and c.Flash takes any key, so tug gen types the
keys the app declares with tug.Flash, as the starters do in flash.go:
var (
Success = tug.Flash[string]("success")
RecoveryCodes = tug.Flash[[]string]("recoveryCodes")
)export interface FlashData {
recoveryCodes?: string[]
success?: string
}
declare module '@inertiajs/core' {
export interface InertiaConfig {
sharedPageProps: SharedProps
flashDataType: FlashData
}
}Each key is optional, as a page shows the flash of the request before it,
some keys or none, and its value is typed as encoding/json writes it. So
usePage().flash.success is a string | undefined, and flash.sucess
doesn't compile. An app that declares no key gets no flashDataType, and
may declare its own, as one made before v0.33.0 does in
resources/js/types.ts, which goes once the app declares a key, as the
two would clash. forms.md has flash data itself.
export const routes = {
'files': { method: 'get', path: '/files/{path...}' },
'posts.destroy': { method: 'delete', path: '/posts/{id}' },
'posts.index': { method: 'get', path: '/{$}' },
'posts.show': { method: 'get', path: '/posts/{id}' },
} as const
export interface Params {
'files': { path: string | number }
'posts.destroy': { id: string | number }
'posts.index': Record<string, never>
'posts.show': { id: string | number }
}
export type RouteName = keyof Paramsrouteshas each named route's method, in lower case, and its path as ServeMux has it: with its group's prefix, and with the{$}that tug adds to a path ending in/. A route for any method, fromAny, is listed as'get'.Paramshas a value for each wildcard, a string or a number, andRecord<string, never>for a route without one.RouteNameis any of the names. A route without a name isn't here: name it to use it from the frontend.
route(name, params) builds the path. It needs params when the path has
wildcards, and takes none when it hasn't:
route('posts.show', { id: 42 }) // '/posts/42'
route('posts.index') // '/'
route('files', { path: 'reports/2026/q3 summary.pdf' }) // '/files/reports/2026/q3%20summary.pdf'
A {id} wildcard gets its value escaped whole, so a / in it becomes
%2F. A {path...} wildcard gets its value escaped between the slashes,
which stay. {$} is replaced with nothing, which leaves the / before it.
Like App.URL, route() returns a path, not a whole URL. A route's method
is for the component to say, as a link that deletes does:
<Link href={route('posts.destroy', { id: post.id })} method="delete" as="button">
Delete
</Link>A form sends what its route's handler binds, and the handler binds a Go
struct, which Takes on the route names, as a value of it. The route
needs its name first, as the types are by name:
type LoginInput struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
Remember bool `json:"remember"`
}
app.Post("/login", a.guestsOnly(a.login)).Name("login.store").Takes(LoginInput{})tug can't see what a handler binds, as one behind a wrapper, as the auth
starter's a.guestsOnly(a.login) is, is a function like any other, so
the route says it. routes.ts gets the struct, and Inputs, each route
that takes one by its name, and form():
export interface LoginInput {
email: string
password: string
remember: boolean
}
export interface Inputs {
'login.store': LoginInput
}form(name, params) is a route as a form's action, its path, built as
route() builds it, and its method, { url, method }, which Inertia's
<Form action> and useForm take, so a form's method comes from the
route, and can't come apart from it. React's <Form> takes the type of
what it sends, and then takes only its keys for errors, resetOnError
and clearErrors, so a key that isn't the struct's, or a field renamed
in Go, is a type error:
import { Form } from '@inertiajs/react'
import { form, type Inputs } from '../../tug/routes'
<Form<Inputs['login.store']> action={form('login.store')} resetOnError={['password']}>
{({ errors }) => <InputError message={errors.email} />}
</Form>Vue's and Svelte's <Form> take their type from createForm, which
makes a <Form> of what it sends, as the starters' pages do:
<script setup lang="ts">
import { createForm } from '@inertiajs/vue3'
import { form, type Inputs } from '@/tug/routes'
const LoginForm = createForm<Inputs['login.store']>()
</script>
<template>
<LoginForm v-slot="{ errors }" :action="form('login.store')" :reset-on-error="['password']">
<InputError :message="errors.email" />
</LoginForm>
</template><script lang="ts">
import { createForm } from '@inertiajs/svelte'
import { form, type Inputs } from '@/tug/routes'
const LoginForm = createForm<Inputs['login.store']>()
</script>
<LoginForm action={form('login.store')} resetOnError={['password']}>
{#snippet children({ errors })}
<InputError message={errors.email} />
{/snippet}
</LoginForm>It's Inertia's own <Form>, with a type. Svelte's leaves out of its type
the methods bind:this gives, so a page that calls one, as the Svelte
starter's register page calls validator(), keeps the <Form> that
takes none. useForm<Inputs['login.store']>(…) types a form's data the
same way, in React, Vue and Svelte. An input's name="email" is text
TypeScript doesn't read, so a typo there is for the form's own tests to
catch.
An input's keys are the names validate gives its errors, its json tag's
name, else its form tag's, else the field's own, which is how Bind reads
a form too. A field whose form tag, or query tag, names it otherwise is
an error tug gen stops on, as the form would send it by one name, and its
errors would come back by another. A field with a path tag is the
route's, in form()'s params. A *multipart.FileHeader is File | null,
and a []*multipart.FileHeader is File[], which Inertia sends as
multipart, as Bind reads it.
Takes is checked as the handler binds: Bind, and BindValid, fail
a handler that binds a struct of body fields other than the one its route
takes, as a 500 whose error, in the log, names both, so a struct the
handler moved on from shows at the first request, in the app's tests. A
struct of query or path fields alone, as a list's filters bound beside a
form, is let through.
tug new writes the types. tug dev writes them again after each rebuild,
which a change to a Go file, go.mod or a template starts. tug build
writes them first, then type-checks and builds the frontend. After
changing Go types without tug dev running, run tug gen.
Commit the two files. routes.ts is code the pages import, and the
Dockerfile's frontend stage builds from the files as they are: it has Node
but no Go, so it can't write them.
CI can check that they match the Go, as this repository's workflow does
for examples/inertia. It runs tug gen, then
git diff --exit-code resources/js/tug, which fails when tug gen has
changed the files: when the Go changed, and the types written from it
weren't committed.
In an app's own CI, go run github.com/cuonggt/tug/cmd/tug runs the CLI
of the tug that go.mod requires, with nothing to install. The app runs as
main has it, so give it what it needs to reach Run: a key, and any
will do, as it serves nothing.
- run: npm ci
- name: the types are the Go ones
run: |
export APP_KEY=base64:$(head -c 32 /dev/urandom | base64)
go run github.com/cuonggt/tug/cmd/tug gen
git diff --exit-code resources/js/tug
- run: npm run typecheck