Skip to content

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