|
| 1 | +--- |
| 2 | +slug: custom-functions-in-typescript |
| 3 | +title: Custom Functions in TypeScript |
| 4 | +authors: [peterpeterparker] |
| 5 | +tags: [functions, typescript, sputnik, serverless] |
| 6 | +date: 2026-03-16 |
| 7 | +--- |
| 8 | + |
| 9 | +For many use cases, frontend code gets you far. But even in smaller projects, you quickly hit a point where you need some backend logic. That's where serverless functions come in. |
| 10 | + |
| 11 | +The idea behind Juno's functions is that you should be able to write them in either Rust or TypeScript. Hooks have been available in TypeScript for a while and are handy for reacting to events. But developers also often want to define their own functions, query or update, akin to adding custom endpoints to an HTTP API. That wasn't supported in TypeScript, until now 🚀. |
| 12 | + |
| 13 | +--- |
| 14 | + |
| 15 | +## Custom Functions |
| 16 | + |
| 17 | +Custom functions let you define callable endpoints directly inside your Satellite, which can be explicitly invoked from your frontend or from other services. |
| 18 | + |
| 19 | +There are two kinds. |
| 20 | + |
| 21 | +A **query** is read-only. It returns data without touching any state, and it's fast. Use it when you just need to fetch something. |
| 22 | + |
| 23 | +An **update** can read and write. Use it when your logic needs to persist data, trigger side effects, or when you need the response to be certified, making it suitable for security-sensitive use cases where data integrity must be guaranteed. |
| 24 | + |
| 25 | +```typescript |
| 26 | +import { defineQuery } from "@junobuild/functions"; |
| 27 | + |
| 28 | +export const ping = defineQuery({ |
| 29 | + handler: () => "pong" |
| 30 | +}); |
| 31 | +``` |
| 32 | + |
| 33 | +The functions can be described with optional arguments and results. They are strongly typed both at runtime and at build time thanks to a new type system, and their handlers can be synchronous or asynchronous. |
| 34 | + |
| 35 | +```typescript |
| 36 | +import { defineUpdate } from "@junobuild/functions"; |
| 37 | +import { j } from "@junobuild/schema"; |
| 38 | + |
| 39 | +const ArgsSchema = j.strictObject({ |
| 40 | + name: j.string() |
| 41 | +}); |
| 42 | + |
| 43 | +const ResultSchema = j.strictObject({ |
| 44 | + message: j.string() |
| 45 | +}); |
| 46 | + |
| 47 | +export const myUpdate = defineUpdate({ |
| 48 | + args: ArgsSchema, |
| 49 | + returns: ResultSchema, |
| 50 | + handler: async ({ name }) => { |
| 51 | + // your logic here |
| 52 | + return { message: `Saved ${name}.` }; |
| 53 | + } |
| 54 | +}); |
| 55 | +``` |
| 56 | + |
| 57 | +--- |
| 58 | + |
| 59 | +## Auto-generated Bindings |
| 60 | + |
| 61 | +Another part that makes this genuinely fun to use: when you build your project, a type-safe client API is automatically generated based on your function definitions. No glue code, no manual wiring, no thinking about serialization. Your functions are simply available through the `functions` namespace. |
| 62 | + |
| 63 | +```typescript |
| 64 | +import { functions } from "../declarations/satellite/satellite.api.ts"; |
| 65 | + |
| 66 | +await functions.ping(); |
| 67 | +await functions.myUpdate({ name: "David" }); |
| 68 | +``` |
| 69 | + |
| 70 | +Define the shape on the backend, call it from the frontend with full type safety. That's it. |
| 71 | + |
| 72 | +--- |
| 73 | + |
| 74 | +## Schema Types |
| 75 | + |
| 76 | +Arguments and return types are optional, but when you need them, Juno provides a type system built on top of [Zod](https://zod.dev) to let you define their shapes. |
| 77 | + |
| 78 | +```typescript |
| 79 | +import { j } from "@junobuild/schema"; |
| 80 | +``` |
| 81 | + |
| 82 | +Those schemas are validated at runtime and used at build time to generate all the necessary bindings. You define the shape once, you get safety everywhere. |
| 83 | + |
| 84 | +```typescript |
| 85 | +const Schema = j.strictObject({ |
| 86 | + name: j.string(), |
| 87 | + age: j.number() |
| 88 | +}); |
| 89 | +``` |
| 90 | + |
| 91 | +Since you will likely need some environment-specific types, `j` extends Zod with `j.principal()` and `j.uint8array()`. |
| 92 | + |
| 93 | +```typescript |
| 94 | +const Schema = j.strictObject({ |
| 95 | + owner: j.principal(), |
| 96 | + data: j.uint8array() |
| 97 | +}); |
| 98 | +``` |
| 99 | + |
| 100 | +And when your function needs to handle multiple distinct input shapes, reach for `discriminatedUnion`: |
| 101 | + |
| 102 | +```typescript |
| 103 | +import { defineUpdate } from "@junobuild/functions"; |
| 104 | +import { j } from "@junobuild/schema"; |
| 105 | + |
| 106 | +const Schema = j.discriminatedUnion("type", [ |
| 107 | + j.strictObject({ type: j.literal("cat"), indoor: j.boolean() }), |
| 108 | + j.strictObject({ type: j.literal("dog"), breed: j.string() }) |
| 109 | +]); |
| 110 | + |
| 111 | +export const registerPet = defineUpdate({ |
| 112 | + args: Schema, |
| 113 | + handler: ({ args }) => { |
| 114 | + if (args.type === "cat") { |
| 115 | + // handle cat |
| 116 | + } else { |
| 117 | + // handle dog |
| 118 | + } |
| 119 | + } |
| 120 | +}); |
| 121 | +``` |
| 122 | + |
| 123 | +Long story short, `j` is Zod with a few extras and everything you need to strongly type your functions. |
| 124 | + |
| 125 | +--- |
| 126 | + |
| 127 | +## References |
| 128 | + |
| 129 | +Following sections of the documentation have been updated: |
| 130 | + |
| 131 | +- [Development](/docs/build/functions/development/) |
| 132 | +- [Guides](/docs/guides/typescript) |
| 133 | +- [References (SDK, schema, etc.)](/docs/reference/functions/typescript) |
| 134 | + |
| 135 | +--- |
| 136 | + |
| 137 | +I'm genuinely excited about these improvements and can totally see myself not using Rust for writing serverless functions in most of my projects in a near future. Not entirely there yet, HTTPS outcalls support in TypeScript is still coming, but getting there. |
| 138 | + |
| 139 | +To infinity and beyond<br />David |
0 commit comments