Skip to content

Add runtime types#185

Merged
jho406 merged 9 commits intov2from
add-runtime-types
Jan 17, 2026
Merged

Add runtime types#185
jho406 merged 9 commits intov2from
add-runtime-types

Conversation

@jho406
Copy link
Collaborator

@jho406 jho406 commented Jan 2, 2026

This adds support for runtime types using deepkit and resolves #168. What makes this approach more useful is that it's a typescript first approach to writing types and we still get the developer happiness of what makes rails rails.

In ruby land, we often come across tools (things like graphql, typelizer, etc) that encourage

  1. Annotating types in ruby land,
  2. then running a rake task to generate typescript,
  3. and finally import the new types into your components to use.

This flow always felt a bit off to me, for a few reasons:

  1. The effectiveness of these tools depends inferring database types from your active record models, but that forces you to move a chunk of business models to to frontend in order to take advantage of types when deriving new values. In other words, it makes your backend a big dumb provider and basically removes much of the value that Rails helpers provides.
  2. Moving a chunk of business models to the frontend often invites opportunity to create diverging validation or business logic. It can leads to subtle bugs.
  3. For me, its been a big "so where the value?". Type safety? Typescript annotations in ruby are all theater anyway; especially so with attributes that are derived and the tool requires you to create methods and annotate for them.
  4. These annotation DSL from these tools is yet another type language to learn. They're less expressive than typescript and the types that get generated may not be ideal.
  5. It forces you to derive state on the JS side of the fence when ruby is just as capable.

Bottom line. These tools tries it best to ensure that the backend and frontend are "in-sync", but it fails to improve the rails experience while also making the typescript experience worse.

Contracts

There has to be a better alternative. Let's let rails be rails, but reinforce the shape and values through runtime contracts on reception (during dev) by your frontend instead of end-to-end typing. Without compromising the rails and typescript experience.

Runtime types

This PR brings in deepkit as an optional dependency. There are no changes to the way folks work already in Superglue. Just pass a type to useContent as you normally would

  import React from 'react'
  import { useContent } from '@thoughtbot/superglue'

  interface Post {
    id: number
    title: string
    content: string
  }

  type PostShowProps = {
    header: string;
    post: Post;
  }

  export default function PostShow() {
    const { header, post} = useContent<PostShowProps>()

    return (
      <div>
        <h1>{header}</h1>

        <ul>
          <li>{post.id}</li>
          <li>{post.title}</li>
          <li>{post.content}</li>
        </ul>
      </div>
    )
  }

And you get runtime types for free:

image

Almost, free. We do have to add

yarn add -D @deepkit/type @deepkit/type-compiler"

and add another plugin to your build tool of choice, e.g. esbuild:

import * as esbuild from 'esbuild'
import svgr from 'esbuild-plugin-svgr'
+ import { DeepkitLoader } from '@deepkit/type-compiler'
import { readFileSync } from 'fs'
import ts from 'typescript'
import path from 'node:path'

const isWatch = process.argv.includes('--watch')

// Deepkit transformation plugin for tsup/esbuild
const deepkitLoader = new DeepkitLoader()

+ const deepkitPlugin = {
+   name: 'deepkit',
+   setup(build) {
+     const loaderMap = {
+       '.ts': 'ts',
+       '.tsx': 'tsx',
+       '.js': 'js',
+       '.jsx': 'jsx',
+     }
+     build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, async (args) => {
+       if (args.path.includes('node_modules')) {
+         return null
+       }
+ 
+       const source = readFileSync(args.path, 'utf8')
+ 
+       const deepkitTransformed = deepkitLoader.transform(source, args.path)
+ 
+       const ext = path.extname(args.path)
+       const loader = loaderMap[ext] || 'js'
+ 
+       return {
+         contents: deepkitTransformed,
+         loader,
+       }
+     })
+   },
+ }

const buildOptions = {
  entryPoints: [
    'app/javascript/application_superglue.tsx',
    'app/javascript/admin/application.js',
    'app/javascript/application.js',
  ],
  bundle: true,
  sourcemap: true,
  format: 'esm',
  outdir: 'app/assets/builds',
  publicPath: '/assets',
+   plugins:  process.env.NODE_ENV === 'production' ? [svgr()] : [deepkitPlugin, svgr()] ,
  metafile: true,
+  conditions: process.env.NODE_ENV === 'production' ? ['production'] : [],
}

if (isWatch) {
  const ctx = await esbuild.context(buildOptions)
  await ctx.watch()
  console.log('Watching for changes...')
} else {
  const result = await esbuild.build(buildOptions)
  console.log(await esbuild.analyzeMetafile(result.metafile))
}

But the tradeoff seems good for such an amazing DX.

A nice side effect: You can use any json builder library with Superglue and still get this experience.

Fragments are now generic types with a __id property. At glance it seems like a
phantom type, but it's an actual property generated by the content proxy that
works with deepkit for runtime validation of the fragment type.
@AlanFoster
Copy link

Awesome - is there an easy way to test a local rails app against this checked out branch locally? 👀

@jho406
Copy link
Collaborator Author

jho406 commented Jan 17, 2026

Kind of. I've build it locally and use file:/ in my app's package.json. Alternatively, you can try linking your main app with this branch. These steps worked for me.

  cd ~/superglue/superglue
  yarn link
  
  cd ~/my_project
  yarn link "@thoughtbot/superglue"

Then you have to ensure you're running a single copy of react

  cd ~/my_project/node_modules/react
  yarn link

  cd ../react-dom
  yarn link

  cd ~/superglue/superglue
  yarn link react
  yarn link react-dom

Now you should be able to go back to your rails app to do something like:

  cd ~/my_project
  bin/dev

@jho406
Copy link
Collaborator Author

jho406 commented Jan 17, 2026

@AlanFoster Note that you'd still have to follow the instructions in this pr to add the deepkit plugin. I still need to update superglue_rails to autogenerate that.

@jho406 jho406 merged commit 724f754 into v2 Jan 17, 2026
3 checks passed
@jho406
Copy link
Collaborator Author

jho406 commented Feb 13, 2026

@AlanFoster I released alpha.11 with runtime types, give it a try and let me know how it feels. Docs here: https://thoughtbot.github.io/superglue/2.0.alpha/runtime-types/

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