tug v0.45.0

Guía 15

TypeScript

Esta página aún no está traducida, así que está en inglés. Tradúcela en GitHub

En esta página
  1. What you get
  2. Vue and Svelte
  3. How tug gen learns the app
  4. From Go to TypeScript
  5. Shared props
  6. Flash data
  7. Routes
  8. Forms
  9. Keeping them current

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.

What you get

Two files, in resources/js/tug:

  • pages.ts: an interface for each Go struct the props use, SharedProps, Pages, and PageProps<'Component'>, which is a page's own props and the shared ones.
  • routes.ts: the named routes, the values each one's path needs, and route(), which builds a route's path as App.URL does 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:

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:

TSX
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'>: no tug.Page declared that component, so it isn't in keyof 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 missing id.
  • 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.

Vue and Svelte

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:

Svelte
<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:

Vue
<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.

How tug gen learns the app

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.Page declared. It records each component and its props type when it's called, which for a package-level var is before main starts. Config.ErrorPage is there too, with ErrorPageProps: { status: number; message: string }. A page rendered with c.Inertia("Name", props), and never declared, isn't.
  • Shared props: the values given to Share, by their Go type. Props that ShareFunc or inertia.WithProps add 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 prints

The 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.

From Go to TypeScript

The types say what encoding/json writes:

Go
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"`
}
TypeScript
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.
  • omitempty or omitzero makes the property optional: draft?: boolean.
  • ,string makes it a string, as encoding/json quotes the value.
  • An embedded struct with no json name has its fields promoted into the struct's own, as Timestamps above; 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 {}.

Shared props

SharedProps has a property for each value given to Share:

Go
pages.Share("appName", "blog")
TypeScript
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:

TSX
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:

Go
// 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:

TypeScript
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

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:

Go
var (
	Success       = tug.Flash[string]("success")
	RecoveryCodes = tug.Flash[[]string]("recoveryCodes")
)
TypeScript
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.

Routes

TypeScript
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 Params
  • routes has 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, from Any, is listed as 'get'.
  • Params has a value for each wildcard, a string or a number, and Record<string, never> for a route without one.
  • RouteName is 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:

TypeScript
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:

TSX
<Link href={route('posts.destroy', { id: post.id })} method="delete" as="button">
  Delete
</Link>

Forms

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:

Go
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():

TypeScript
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:

TSX
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:

Vue
<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>
Svelte
<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.

Keeping them current

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.

YAML
- 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
tug