Skip to main content

Code Functions in TypeScript

Learn how to develop, integrate, and extend Juno Satellites with serverless functions written in TypeScript.


Quickstart

Set up your environment to develop and extend a Satellite with custom serverless functions.

First, ensure you have the Juno CLI installed. If you haven't installed it yet, run:

npm i -g @junobuild/cli

At the root of your application, eject the Satellite if you haven't already used a template.

juno dev eject

In a new terminal window, kick off the emulator (requires Docker):

juno dev start --watch

Now, your local development environment is up and running, ready for you to start coding.

Every time you make changes to your code, it will automatically recompile and reload.


Hooks and Data Operations

Serverless Functions are triggered by hooks, which respond to events occurring in the Satellite, such as setting a document. Before implementing a hook that manipulates data ("backend"), let's first set up a JavaScript function in your ("frontend") dApp.

Define a setter function in your frontend dApp as follows:

interface Example {
hello: string;
}

let key: string | undefined;

const set = async () => {
key = crypto.randomUUID();

const record = await setDoc<Example>({
collection: "demo",
doc: {
key,
data: {
hello: "world"
}
}
});

console.log("Set done", record);
};

This code generates a key and persists a document in a collection of the Datastore named "demo".

Additionally, add a getter to your code:

const get = async () => {
if (key === undefined) {
return;
}

const record = await getDoc({
collection: "demo",
key
});

console.log("Get done", record);
};

Without a hook, executing these two operations one after the other would result in a record containing "hello: world".

Now, let's create a hook within src/satellite/index.ts with the following implementation:

import { defineHook, type OnSetDoc } from "@junobuild/functions";
import {
decodeDocData,
encodeDocData,
setDocStore
} from "@junobuild/functions/sdk";

// The data shape stored in the Satellite's Datastore
interface Person {
hello: string;
}

// We declare a hook that listens to changes in the "demo" collection
export const onSetDoc = defineHook<OnSetDoc>({
collections: ["demo"],
run: async (context) => {
// Decode the document's data (stored as a blob)
const data = decodeDocData<Person>(context.data.data.after.data);

// Update the document's data by enhancing the "hello" field
const updated = {
hello: `${data.hello} checked`
};

// Encode the data back to blob format
const encoded = encodeDocData(updated);

// Save the updated document using the same caller, collection, and key
await setDocStore({
caller: context.caller,
collection: context.data.collection,
key: context.data.key,
doc: {
data: encoded,
description: context.data.data.after.description,
version: context.data.data.after.version
}
});
}
});

Once saved, your code should be automatically compiled and deployed.

When testing this feature, if you wait a bit before calling the getter, you should now receive the modified "hello: world checked" text set by the hook. This delay occurs because serverless Functions execute fully asynchronously, separate from the request-response cycle between your frontend and the Satellite.


Assertions

Assertions allow you to validate or reject operations before they are executed. They're useful for enforcing data integrity, security policies, or business rules inside your Satellite, and they run synchronously during the request lifecycle.

import { decodeDocData } from "@junobuild/functions/sdk";
import { defineAssert, type AssertSetDoc } from "@junobuild/functions";

interface NoteData {
text: string;
}

export const assertSetDoc = defineAssert<AssertSetDoc>({
collections: ["notes"],
assert: (context) => {
const data = decodeDocData<NoteData>(context.data.data.proposed.data);

if (data.text.toLowerCase().includes("hello")) {
throw new Error("The text must not include the word 'hello'");
}
}
});

This example ensures that any document added to the notes collection does not contain the word "hello" (case-insensitive). If it does, the operation is rejected before the data is saved.


Validating with Zod

To simplify and strengthen your assertions, we recommend using Zod — a TypeScript-first schema validation library. It's already bundled as a dependency of the @junobuild/functions package, so there's nothing else to install.

Here's how you can rewrite your assertion using Zod for a cleaner and more declarative approach:

import { z } from "zod";
import { decodeDocData } from "@junobuild/functions/sdk";
import { defineAssert, type AssertSetDoc } from "@junobuild/functions";

interface NoteData {
text: string;
}

const noteSchema = z.object({
text: z
.string()
.refine(
(value) => !value.toLowerCase().includes("hello"),
"The text must not include the word 'hello'"
)
});

export const assertSetDoc = defineAssert<AssertSetDoc>({
collections: ["notes"],
assert: (context) => {
const data = decodeDocData<NoteData>(context.data.data.proposed.data);
noteSchema.parse(data);
}
});

This approach is more expressive, easier to extend, and automatically gives you type safety and error messaging. If the validation fails, parse() will throw and reject the request.


Calling Canisters on ICP

This is useful if you want to:

  • Fetch or modify data in other smart contracts
  • Interact with standard canisters like ledger or governance
  • Trigger behavior on other dapps

Here's an example that calls another canister’s method and logs the result:

import { call } from "@junobuild/functions/ic-cdk";
import { defineHook, type OnSetDoc } from "@junobuild/functions";
import { IDL } from "@dfinity/candid";
import { Principal } from "@dfinity/principal";

// Define Candid types
const SubAccount = IDL.Vec(IDL.Nat8);
const Account = IDL.Record({
owner: IDL.Principal,
subaccount: IDL.Opt(SubAccount)
});

const Icrc1Tokens = IDL.Nat;

// Define TypeScript interfaces
export type SubAccountType = Uint8Array | number[];

export interface AccountType {
owner: Principal;
subaccount: [] | [SubAccountType];
}

export type Icrc1TokensType = bigint;

// Define the onSetDoc hook
export const onSetDoc = defineHook<OnSetDoc>({
collections: ["notes"],
run: async (context) => {
const account: AccountType = {
owner: Principal.from(context.caller),
subaccount: []
};

const icpLedgerId = Principal.fromText("ryjl3-tyaaa-aaaaa-aaaba-cai");

const balance = await call<Icrc1TokensType>({
canisterId: icpLedgerId,
method: "icrc1_balance_of",
args: [[Account, account]],
result: Icrc1Tokens
});

console.log("Balance:", balance);
}
});

This example performs a call to the ICP Ledger canister using the icrc1_balance_of method to retrieve the token balance for the calling user. The result is printed to the log using console.log.

The args field contains a tuple with the Candid type definition and the corresponding JavaScript value.

The call function handles both encoding the request and decoding the response using the provided types.

To encode and decode these calls, you need JavaScript structures that match the Candid types used by the target canister. Currently, the best (and slightly annoying) way to get them is to copy/paste from the service output generated by tools like didc. It's not ideal, but that’s the current status. We’ll improve this in the future — meanwhile, feel free to reach out if you need help finding or shaping the types.


Handling Multiple Collections

If your hook applies to many collections, a switch statement is one way to route logic:

import { defineHook, type OnSetDoc } from "@junobuild/functions";

export const onSetDoc = defineHook<OnSetDoc>({
collections: ["posts", "comments"],
run: async (context) => {
switch (context.data.collection) {
case "posts":
// Handle posts logic
break;
case "comments":
// Handle comments logic
break;
}
}
});

While this works, you might accidentally forget to handle one of the observed collections. To prevent that, you can use a typed map:

import {
defineHook,
type OnSetDoc,
type OnSetDocContext,
type RunFunction
} from "@junobuild/functions";

const collections = ["posts", "comments"] as const;

type OnSetDocCollection = (typeof collections)[number];

export const onSetDoc = defineHook<OnSetDoc>({
collections,
run: async (context) => {
const fn: Record<OnSetDocCollection, RunFunction<OnSetDocContext>> = {
posts: yourFunction,
comments: yourOtherFunction
};

await fn[context.data.collection as OnSetDocCollection]?.(context);
}
});

This ensures all collections are handled and you'll get a TypeScript error if one is missing.