This guide walks you through building a handwritten Deno SDK for a RESTful HTTP API, covering everything from project setup to publishing on Deno's JavaScript Registry (JSR). The focus is on making your SDK run smoothly in both standard Deno and Supabase Edge Functions (which run on Deno Deploy).
Why Handwrite Your SDK?
Writing the SDK by hand instead of generating it from an OpenAPI spec gives you complete control. You can make the library lightweight, idiomatic, and perfectly tuned for the Deno runtime. Handwritten code avoids the bloat and quirks that auto-generated clients often introduce. You get direct integration with Deno's APIs like fetch, better security practices, and code that's more ergonomic for developers and easier to maintain over time.
Let's dive in step by step.
1. Project Setup and Structure
Start with a clear, idiomatic structure. Deno modules typically use mod.ts at the root as the entry point (similar to Rust modules). This file re-exports the main functionality of your SDK. Avoid using index.ts—Deno doesn't treat it specially like Node does, which can cause confusion.
Directory Layout
Organize code into modules so it's easy to maintain and so consumers can import what they need without pulling in everything:
my-api-sdk/
├── mod.ts # Entry point: exports main client and types
├── client.ts # Core API client implementation
├── endpoints/
│ ├── users.ts # Module for "users" API endpoints
│ └── projects.ts # Module for "projects" API endpoints
├── types.ts # Type definitions for request/response payloads
├── utils.ts # Utility functions (e.g. for building queries)
├── tests/
│ └── client_test.ts # Unit tests for the client
├── README.md # Documentation and usage examples
├── deno.json # Deno configuration (including JSR package info)
└── deno.lock # Lock file for dependencies (auto-generated)
In mod.ts, re-export the main components so consumers only need to import from the top-level module:
export { APIClient } from "./client.ts";
export type { User, Project } from "./types.ts";
This lets users do import { APIClient } from "https://deno.land/x/my_api_sdk/mod.ts"; without digging into subfolders.
Why Modular Design Matters
Splitting your code into multiple files keeps things organized and prevents one giant file. It also helps with dependency management—you avoid pulling in heavy dependencies unless absolutely needed. This is crucial for Deno Deploy and Supabase Edge Functions because any imported code runs at deployment time. Unnecessary code slows down cold starts or causes compatibility issues.
For example, if most API calls are simple fetches but one endpoint requires a PDF generation library, keep that in a separate module. Don't export it from mod.ts by default. Let consumers import it separately only when needed. As one Deno developer notes, "it's unwise to export all functions in mod.ts... because that would mean users download [e.g. a Puppeteer] dependency every time, even if they don't need that functionality." The key is thinking critically about your dependency tree and keeping the entry point lean.
Deno Configuration
Create a deno.json (or deno.jsonc) file in your project root. This file configures TypeScript settings, import maps, linting rules, and JSR package fields:
{
"compilerOptions": {
"strict": true
},
"imports": {
"@my-sdk/": "./"
},
"lint": {
"rules": {
"tags": ["recommended", "jsr"],
"include": [
"explicit-function-return-type",
"explicit-module-boundary-types",
"camelcase",
"single-var-declarator",
"no-console"
]
}
},
"fmt": {
"indentWidth": 2,
"lineWidth": 100,
"singleQuote": false
},
"package": {
"name": "@yourname/my-api-sdk",
"version": "0.1.0",
"description": "SDK for My API",
"license": "MIT"
}
}
If using JSR, you can also put the package info in a separate jsr.json (more on that later).
Make sure strict type-checking is enabled to catch errors early and produce a robust SDK. The lint configuration uses the recommended and jsr rule tags as a baseline, plus additional rules for explicit typing and consistent style—we'll cover these in detail in Section 9.
In summary, a well-structured project uses a clear entry point (mod.ts), logical module breakdown, and a config file for Deno. This makes your SDK easy to navigate and consume.
2. Secure and Ergonomic API Key Handling
Most REST APIs require an API key or token for authentication. Handling this securely and ergonomically is crucial. The SDK should make it easy for developers to supply the key without risking exposure.
Never hard-code keys or secrets. Design your API client to accept credentials at runtime:
// client.ts
export interface APIClientOptions {
apiKey: string;
baseUrl?: string;
}
export class APIClient {
#apiKey: string;
#baseUrl: string;
constructor(options: APIClientOptions) {
this.#apiKey = options.apiKey;
this.#baseUrl = options.baseUrl ?? "https://api.example.com/v1";
}
// ... (methods will use this.#apiKey)
}
This approach is both ergonomic and secure:
- Users retrieve the API key from their environment (or Supabase secret store) and pass it in:
const client = new APIClient({ apiKey: Deno.env.get("MYAPI_KEY")! });
- The key is stored in a private field (
#apiKey), not exposed publicly - The SDK should never log this key or accidentally include it in error messages or exceptions
Using Environment Variables
It's common to use environment variables for secrets. In Supabase Edge Functions, you define secrets in the dashboard or CLI, and they become available via Deno.env.get inside your function. For local development, developers can use a .env file or export environment variables.
Your SDK can leverage this by convention. For instance, you could allow APIClient to default to an env var if no key is provided:
if (!options.apiKey) {
const envKey = Deno.env.get("MYAPI_KEY");
if (!envKey) {
throw new Error("API key not provided and MYAPI_KEY env var is not set");
}
this.#apiKey = envKey;
} else {
this.#apiKey = options.apiKey;
}
This way, developers can simply ensure MYAPI_KEY is set and call new APIClient({}). This is similar to how some official SDKs work—for example, the OpenAI Deno SDK will automatically read an OPENAI_API_KEY from the environment if you don't explicitly pass one. Note that if your library accesses Deno.env, users will need to run their code with --allow-env permission in Deno (or supply the key manually to avoid that requirement).
Best Practices
-
Never expose the key: All HTTP requests should send the key securely (usually in an HTTP header, e.g.,
Authorization: Bearer <token>or a custom header) - Avoid query params: Keys in URLs can end up in logs
- Redact in debug logs: If your SDK prints debug info, omit or redact the key
- Use Supabase secrets: Store API keys as secrets in your Supabase project and access via environment variables—this keeps secrets out of your code and repository
In your Supabase Edge Function code using the SDK:
const client = new APIClient({ apiKey: Deno.env.get("MYAPI_KEY")! });
The Edge Function can then securely use the SDK without hardcoding the secret.
Ergonomics Tip
You might allow configuring the client with additional auth options, such as passing a custom header name or using different auth schemes (some APIs use API key in query param, etc.). Design the APIClientOptions accordingly, but provide sane defaults for the common case. For example, if the API expects Authorization: Bearer, just require the token and do the rest internally.
3. Implementing the API Client (HTTP Requests & Error Handling)
With structure and auth in place, implement the functions that call the RESTful API using Deno's HTTP capabilities. Deno provides the standard fetch() Web API for HTTP requests, which works on both the Deno CLI and Deno Deploy. This means your SDK can use fetch directly to call the remote API.
Making HTTP Requests
Inside your client methods, construct the request to the API endpoint:
/**
* Retrieves a user by their unique identifier.
* @param userId - The unique identifier of the user to retrieve.
* @returns A promise that resolves to the User object.
* @throws {APIError} When the API returns a non-OK response.
* @example
* ```
ts
* const user = await client.getUser("abc123");
* console.log(user.email);
*
*/
async getUser(userId: string): Promise {
const url = ${this.#baseUrl}/users/${userId};
const res = await fetch(url, {
method: "GET",
headers: {
"Authorization": Bearer ${this.#apiKey},
"Accept": "application/json"
}
});
if (!res.ok) {
// Handle HTTP error
const errorText = await res.text();
throw new APIError(res.status, errorText || res.statusText);
}
const data: unknown = await res.json();
return data as User;
}
In this snippet, `APIError` is a custom error class you define to capture HTTP status and message:
```typescript
/**
* Custom error class for API-related errors.
* Captures HTTP status code and error message for easier debugging.
*/
export class APIError extends Error {
status: number;
constructor(status: number, message: string) {
super(`API request failed with status ${status}: ${message}`);
this.status = status;
this.name = "APIError";
}
}
Using a custom error class makes it easier for users to distinguish API errors from other errors (they can catch APIError). Make error messages clear and concise—they might surface in logs. Deno's style guide suggests using sentence case, no trailing period, and including relevant info. For example, "Cannot fetch user: received 401 Unauthorized" is more helpful than a vague "Failed request".
Strong TypeScript Types
Define TypeScript interfaces or types for the API's responses and request payloads in types.ts. This provides strong typing for anyone using your SDK, enabling autocompletion and compile-time checks:
/** Represents a user in the system. */
export interface User {
/** Unique identifier for the user. */
id: string;
/** Display name of the user. */
name: string;
/** Email address of the user. */
email: string;
}
/** Input parameters for creating a new user. */
export interface CreateUserInput {
/** Display name for the new user. */
name: string;
/** Email address for the new user. */
email: string;
// ...other fields
}
Then use these types in your methods:
/**
* Creates a new user in the system.
* @param input - The user data for creation.
* @returns A promise that resolves to the newly created User.
* @throws {APIError} When the API returns a non-OK response.
*/
async createUser(input: CreateUserInput): Promise<User> {
const res = await fetch(`${this.#baseUrl}/users`, {
method: "POST",
headers: {
"Authorization": `Bearer ${this.#apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify(input)
});
if (!res.ok) {
const err = await res.text();
throw new APIError(res.status, err || "Unknown error");
}
const data: unknown = await res.json();
return data as User;
}
By casting or declaring the JSON result as User, developers get typed data back (assuming the API returns JSON matching that interface). Ensure your types stay updated if the API changes. It's good to document in your README which version of the REST API your SDK is targeting.
Idiomatic TypeScript Usage
Embrace TypeScript features to make your SDK easy to use and maintain:
-
Avoid
anyentirely: Useunknownfor truly dynamic values and narrow the type. The lint ruleno-explicit-anywill enforce this—usinganyundermines your SDK's type safety. -
Use explicit return types: Every exported function should have an explicit return type annotation. This is enforced by
explicit-function-return-typeandexplicit-module-boundary-typeslint rules, and it's required for JSR's "no slow types" policy. - Narrow types where possible: If an endpoint returns one of several shapes, consider using a union type or discriminated union for the response.
- Optional vs required options: Use optional properties in interfaces or function parameters for truly optional settings, and provide default behavior when not given (as we did with baseUrl defaulting to the API's base).
- Follow Deno conventions: Exported functions should take at most 2 positional arguments and otherwise use an options object. If you have many config options for a function, group them in an object rather than having a long parameter list.
-
Use type-only imports: When importing something used only as a type, use
import type { ... }to separate types from values. This is enforced by theverbatim-module-syntaxlint rule and ensures your code works correctly across module systems.
Dependency Management
One advantage of Deno is that many things are built-in (fetch, URL, crypto APIs, etc.), so you might not need any third-party HTTP library or dotenv library as you would in Node. Keep external dependencies minimal. If you do need a dependency (say, a specific date library or a base64 encoder from Deno std), import it by URL and always pin the version:
import { encodeBase64 } from "https://deno.land/std@0.200.0/encoding/base64.ts";
Pinning to a specific version ensures your SDK doesn't break unexpectedly if the dependency updates. The no-unversioned-import lint rule enforces this—unversioned imports can cause reproducibility issues and are flagged by JSR.
Also avoid Node-specific libraries; instead use Deno's equivalents or polyfills. This guarantees your SDK works on Supabase Edge (Deno Deploy), where Node built-ins might not be available or allowed. In general, do not depend on Node's fs, path, etc. unless you specifically include a polyfill or use Deno's std/node shims (but those might not all work on Deploy).
The lint rules no-node-globals and no-process-global will catch accidental use of Node globals like Buffer, global, or process. Use Web APIs or Deno APIs instead (e.g., Deno.env instead of process.env, globalThis instead of global).
Aim for zero or few dependencies—this improves your package quality and JSR score (no unreviewed third-party code).
Error Management Best Practices
- Define custom error types if needed
- Use clear messages
- Throw errors when something is truly an exceptional case (like a non-OK HTTP response)
- For expected "errors" (like API returning a 404 for a missing resource), consider whether your SDK should return undefined/null or throw—often throwing is fine, and the user can catch it
- Document what exceptions can be thrown by your functions using
@throwsJSDoc tags - Don't leak sensitive info in error messages—for example, if the API returns an HTML error page, you might not want to include the entire page in the thrown error message (it could be huge or contain irrelevant info). Maybe include just status and statusText, or a parsed JSON error message if the API provides one.
By carefully handling errors and types, you make your SDK reliable for developers to use in production—especially in edge functions, where debugging can be harder, clear errors are a boon.
4. Optimizing for Deno Deploy and Supabase Edge Functions
Supabase Edge Functions run on Deno Deploy, a sandboxed, distributed runtime for Deno. The good news is that if your SDK runs in Deno CLI with only standard APIs, it will likely run on Deno Deploy as well. Here are best practices to ensure full compatibility and performance:
Avoid Unsupported APIs
Deno Deploy (and thus Supabase Edge) does not allow certain operations:
- Binding to a local port (
Deno.listen) - Spawning subprocesses (
Deno.run) - Persistent file system access
Your SDK should not use these. Typically, an API client SDK wouldn't need them anyway. Stick to fetch, in-memory computation, and in-memory caches if needed. If you need to write temporary data (e.g., caching a token), use in-memory storage or Deno KV (Deno.openKv) which is supported on Deploy. Avoid requiring a filesystem. In short, assume a sandboxed environment.
The no-deprecated-deno-api lint rule ensures you're not using deprecated Deno APIs that might be removed in future versions.
No Long-Running Tasks
Edge functions are meant for short, quick executions. If your SDK were to do something like polling or waiting on events, it could cause issues. Each invocation of an Edge Function should complete promptly. Design SDK calls to complete once the network request finishes. If the API requires webhooks or long polling, that logic might need to be handled differently in an edge context or avoided.
Avoid Blocking Operations
The no-sync-fn-in-async-fn lint rule catches synchronous function calls inside async functions—this is important because blocking I/O in an async context stalls the event loop. For example, calling Deno.readFileSync inside an async function that could use await Deno.readFile would be flagged. In edge settings where quick responses matter, always use non-blocking, await-based calls.
The require-await rule flags async functions that don't actually await anything inside—these could just be normal functions, and removing unnecessary async saves overhead and avoids confusion.
Cold Start Performance
In Deno Deploy, when a function instance is cold-started, it has to download and compile your code (and dependencies). Keep your SDK lightweight for faster cold starts:
Lazy import heavy code: If you have any optional feature that is large (as discussed in section 1), don't load it unless needed. You can even use dynamic imports for optional modules. For example, if most endpoints are lightweight but one uses a big dependency, consider separating it. In our case, if one feature required, say, a PDF generation library, we'd keep that in a separate module—"that way it will be used much [less often] and only when specifically needed."
Minimize global work: Don't perform expensive computations or I/O at the top-level of your module (which runs on import). For example, avoid reading files or making network calls during import. Defer such work to the first function call, or make it explicit (e.g., an init function). This way, the edge function isn't doing unnecessary work on startup. A small in-memory setup (like constructing a static headers object or regex) is fine, but anything more should be reconsidered.
No console logging in production: The no-console lint rule disallows console.log/warn/error calls in library code. Uncontrolled console output can slow down applications and clutter logs. If logging is needed, consider using explicit logger hooks or returning data to the caller. SDKs should be silent by default.
Testing on Deploy: It's wise to test your SDK in a Deno Deploy environment. You can use the Supabase CLI (supabase functions serve) to test locally or deploy a test function. This will catch any compatibility issues—if you accidentally used a Node.js global like Buffer or a module that isn't available, you'll find out.
Environment Variables on Deploy
Environment variables work the same on Deploy as locally. Reading Deno.env in your SDK is fine in edge contexts—there's no --allow-env flag concern on Deploy (it's implicitly allowed for provided env vars).
Avoid Browser-Specific Globals
The no-window and no-window-prefix lint rules prevent using the window object, which is being removed in Deno 2.0. Many web APIs (like fetch, crypto) are available on globalThis—use those directly instead. This ensures your SDK works in edge environments where window isn't defined.
Mark Compatibility in JSR
When publishing, mark your package as compatible with relevant runtimes. JSR allows indicating compatibility with "deno", "node", "bun", "browser", etc. Mark at least deno runtime as compatible, and ideally two or more for a better JSR score.
In summary, write portable Deno code. Stick to Web standard APIs and Deno standard library, and your SDK will naturally be portable to the edge. The lint rules help enforce this automatically.
5. Setting Up Testing (Unit and Integration Tests)
Robust testing ensures your SDK works correctly and continues to do so as it evolves. Deno has built-in testing tools, which you should leverage.
Test Organization
Follow the convention of placing tests in files with the _test.ts suffix. The Deno style guide suggests every module should have a corresponding test module (e.g., client_test.ts for client.ts). This makes it easy to find tests and Deno will recognize them when running deno test.
Here's an example client_test.ts:
import { assertEquals } from "https://deno.land/std@0.200.0/testing/asserts.ts";
import { assertRejects } from "https://deno.land/std@0.200.0/testing/asserts.ts";
import { APIClient, APIError } from "../client.ts";
Deno.test("getUser() returns a User object on success", async () => {
const client = new APIClient({
apiKey: "test-key",
baseUrl: "https://api.example.com"
});
// Stub fetch to simulate API response
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_input: Request | string): Promise<Response> => {
return new Response(
JSON.stringify({ id: "123", name: "Alice", email: "alice@example.com" }),
{ status: 200 }
);
};
const user = await client.getUser("123");
assertEquals(user.name, "Alice");
assertEquals(user.id, "123");
globalThis.fetch = originalFetch; // restore
});
Deno.test("getUser() throws APIError on HTTP error", async () => {
const client = new APIClient({
apiKey: "bad-key",
baseUrl: "https://api.example.com"
});
const originalFetch = globalThis.fetch;
globalThis.fetch = async (): Promise<Response> => {
return new Response("Unauthorized", { status: 401, statusText: "Unauthorized" });
};
await assertRejects(
async () => {
await client.getUser("999");
},
APIError,
"401"
);
globalThis.fetch = originalFetch;
});
This uses Deno's Deno.test function to define test cases. We use assertEquals to check returned data, and assertRejects to verify that an APIError is thrown on a bad response. We monkey-patch globalThis.fetch to simulate API responses, which is a simple way to test without real network calls. (Alternatively, you could allow injecting a custom fetch function into your client for testing, but patching works in a pinch.)
Notice we restore globalThis.fetch after each test to avoid side effects. Deno tests run in a single process by default, so cleanup is important.
Integration Tests
If you want to test against the real API (integration tests), you can mark those tests with a permission requirement and perhaps a special name:
Deno.test({
name: "getUser() against live API",
permissions: { net: true, env: true }
}, async () => {
const apiKey = Deno.env.get("MYAPI_KEY");
if (!apiKey) {
console.warn("Skipping live API test because MYAPI_KEY is not set");
return;
}
const client = new APIClient({ apiKey });
const user = await client.getUser("123");
assertEquals(user.id, "123");
});
Here we used permissions: { net: true, env: true } to allow network and environment access. Running this test would require deno test --allow-net --allow-env. Be cautious with live tests—they might fail due to external factors (network issues, API down, rate limits). Consider separating them from unit tests.
Testing Documentation Examples
Use deno test --doc to type-check and run examples in your JSDoc comments. This ensures your documentation stays accurate and up-to-date:
/**
* Adds two numbers together.
* @param a - First number
* @param b - Second number
* @returns The sum of a and b
* @example
* ```
ts
* import { add } from "./math.ts";
* const result = add(1, 2);
* console.log(result); // 3
*
*/
export function add(a: number, b: number): number {
return a + b;
}
Running `deno test --doc` will execute that example and verify it works.
### Running Tests
Deno's test runner will automatically discover tests and run them in parallel:
```bash
deno test
deno test --coverage # for coverage reports
deno test --doc # also test documentation examples
Aim to cover the main code paths: success, error, edge cases like invalid inputs.
Continuous Testing
Automate tests with CI (GitHub Actions). Run deno lint, deno fmt --check, and deno test on each push to prevent regressions and maintain quality.
Summary
- Write unit tests for logic (stubbing external calls as needed)
- Write integration tests for real API calls (guarded by permissions)
- Write doc tests to verify documentation examples work
- Keep tests alongside code for easy maintenance
- Ensure tests don't leak secrets
- Use
deno lintanddeno fmtto maintain code consistency
A well-tested SDK inspires confidence in users and performs reliably in critical edge function deployments.
6. Example Usage in a Supabase Edge Function
To illustrate how your SDK would be used in context, let's walk through an example of using it inside a Supabase Edge Function.
Scenario
Imagine an Edge Function that receives a request (maybe a webhook or HTTP call) and needs to fetch some data from the third-party API via our SDK, then return a JSON response.
First, in your function's code (say functions/fetch_user_data/index.ts in the Supabase project), import the SDK. If you published to deno.land/x:
import { APIClient } from "https://deno.land/x/my_api_sdk@0.1.0/mod.ts";
If published to JSR, and you have an import map or are using the CLI's support:
import { APIClient } from "@yourname/my-api-sdk";
For simplicity, let's assume a URL import from deno.land/x for this example:
// Follow Supabase Edge Function setup guidelines for the Deno runtime.
// Import the SDK (replace URL with actual registry URL and version)
import { APIClient } from "https://deno.land/x/my_api_sdk@0.1.0/mod.ts";
// Import types separately using type-only import
import type { User } from "https://deno.land/x/my_api_sdk@0.1.0/mod.ts";
// Initialize the client with the API key from environment (Supabase secret)
const apiKey = Deno.env.get("MYAPI_KEY");
if (!apiKey) {
throw new Error("Missing MYAPI_KEY in environment");
}
const client = new APIClient({ apiKey });
// Supabase Edge Function handler
export default async function handler(req: Request): Promise<Response> {
// Parse request
let userId: string;
try {
const body: unknown = await req.json();
if (typeof body === "object" && body !== null && "id" in body) {
userId = String((body as { id: unknown }).id);
} else {
throw new Error("Missing id field");
}
} catch {
return new Response(
JSON.stringify({ error: "Invalid request body" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
try {
const user: User = await client.getUser(userId);
return new Response(JSON.stringify(user), {
headers: { "Content-Type": "application/json" },
});
} catch (err) {
if (err instanceof Error && err.name === "APIError") {
const status = (err as { status?: number }).status ?? 502;
return new Response(
JSON.stringify({ error: err.message }),
{ status, headers: { "Content-Type": "application/json" } }
);
} else {
// In production, you might want a proper logger instead of console
return new Response(
JSON.stringify({ error: "Internal Server Error" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
}
}
Key Points
- We retrieve the API key via
Deno.env.get("MYAPI_KEY"). In Supabase, set this secret in your project before deploying. If not set, we throw at the top—causing the function to fail to boot (which is better than running without a key). - We create an APIClient instance outside the handler. This is good practice because it can be reused across requests in the same function instance (edge function instances are reused for multiple invocations). This avoids re-initializing on every request.
- The handler function is exported as default, following Supabase's expectation for Edge Functions. It accepts a Request and returns a Response.
- We parse the incoming request JSON to get a userId with proper type narrowing (no
any). - We use the SDK (
client.getUser(userId)) to fetch data from the third-party API. - We handle errors appropriately, returning proper HTTP status codes.
This pattern works for any API calls your function needs. The SDK usage on the edge is identical to usage in a regular Deno script—which is the beauty of writing it in Deno. There's no special "edge mode" needed.
Dependency Management in Edge Functions
If your SDK is published to JSR or deno.land/x, you might prefer using an import map or the Supabase CLI's dependency management. Supabase Edge Functions allow third-party code via URL references or CLI bundling.
Example Outcome
With the above function deployed, you can call it via HTTP and it will use the SDK to fetch from the external API and return the result. Supabase provides the scheduling and HTTP interface; your SDK provides the business logic.
When writing documentation for your SDK, include a section "Using with Supabase Edge Functions" with a snippet like the above. It helps users understand setup requirements.
7. Publishing the SDK to JSR
After building and testing your SDK, publish it so others can use it. Deno's JavaScript Registry (JSR) is the recommended way. You can also publish to deno.land/x for additional visibility.
Publishing via JSR
JSR is a package registry for JavaScript/TypeScript, integrated with the Deno ecosystem.
Prepare package metadata: In your deno.json or a separate jsr.json:
-
name: Package name with scope (e.g.,
@yourname/my-api-sdk) -
version: Semantic version (e.g.,
0.1.0) -
exports: Entry point(s), usually
./mod.ts - description: One-liner description
- Optional: license, homepage, repository fields
Example jsr.json:
{
"name": "@yourname/my-api-sdk",
"version": "1.0.0",
"description": "Deno SDK for My API, providing easy REST calls.",
"license": "MIT",
"exports": "./mod.ts"
}
Login and publish:
deno publish
This prompts authentication (likely via GitHub) and uploads your package. Alternatively, npx jsr publish works if you prefer npm's toolchain. Ensure you're publishing from a clean git state matching the version.
On publish, JSR performs checks: type-checking, ensuring no "slow types", generating documentation from JSDoc, and computing a quality score.
No "Slow Types"
JSR enforces that you avoid complex or inferred types that slow down consumers' type-checking. The no-slow-types lint rule (included in the jsr tag) catches these issues early. If you have explicit return types on all exported functions (enforced by explicit-function-return-type), you'll typically pass this check.
If JSR flags slow types, fix them by adding explicit type annotations. You can publish with --allow-slow-types to override, but this is discouraged—it negatively impacts package quality.
Documentation Generation
JSR automatically generates HTML docs from your code comments and README. To ensure quality:
Provide a module doc comment at the top of mod.ts:
/**
* @module
* SDK for interacting with the Example API.
*
* ## Basic Usage
*
* ```
ts
* import { APIClient } from "@yourname/my-api-sdk";
*
* const client = new APIClient({ apiKey: "your-key" });
* const user = await client.getUser("123");
*
*/
export { APIClient } from "./client.ts";
export type { User, CreateUserInput } from "./types.ts";
**Use JSDoc comments** for all exported functions, classes, and interfaces with `@param`, `@returns`, `@throws`, and `@example` tags.
**Run documentation linting:**
```bash
deno doc --lint mod.ts
This catches missing JSDoc for exported symbols or broken example links before publishing.
Document all entry points: If you have submodules meant to be imported, ensure those have module docs too.
JSR Score Factors
JSR gives your package a score based on quality metrics. To maximize it:
-
README or module doc present: Have a README.md or
@moduleJSDoc -
Examples included: Provide usage examples in README or
@exampleJSDoc - Docs for all public APIs: Every exported function/type should have documentation
- No slow types: Pass the slow types check
- Description provided: Include package description in metadata
- Runtime compatibility: Mark at least one runtime (ideally 2+) as compatible
- Provenance: Publish from CI with Sigstore signing for verification
View the score breakdown on your package's JSR page and aim for 100%.
Immutability
Once published, a version is immutable. You can't override 0.1.0 with a fix—publish a new version. Double-check everything before publishing.
Publishing to deno.land/x
deno.land/x fetches from GitHub releases:
- Ensure code is in a public GitHub repo
- Submit your repo at https://deno.land/x
- Create a git tag (e.g.,
v1.0.0) and push it - Users import via
https://deno.land/x/<repo>@v1.0.0/mod.ts
Keep version numbers in sync if using both JSR and deno.land/x.
JSR vs deno.land/x
JSR is more feature-rich with scoring, native TypeScript support, and npm-compatible consumption. deno.land/x is still useful for URL imports. Use both if you like—JSR gives extra quality validation.
After Publishing
Test installation as a user:
deno add @yourname/my-api-sdk
Check the generated documentation on jsr.io for accuracy.
8. Documentation, Maintenance, and Versioning
Keeping your SDK high-quality is ongoing work.
Documentation
README.md: Include:
- Description of the API
- How to import the SDK
- Basic usage examples
- Links to full documentation
This shows up on deno.land/x, GitHub, and JSR.
JSDoc comments: Every exported function, class, and module should have documentation. Write in plain language, explain the what and why. Use Markdown in JSDoc for richer formatting.
Autogenerate docs: Use deno doc --json > docs.json for custom documentation sites, or rely on JSR's auto-generated docs.
Examples and tutorials: Include an /examples directory with sample scripts.
Maintenance
Testing on new Deno versions: Test your SDK on new Deno releases. Supabase Edge Runtime tracks Deno versions (currently compatible with Deno v2.1.4).
Continuous Integration: Set up CI to run deno lint, deno fmt --check, deno doc --lint, and deno test on each commit/PR.
Formatting and linting: Enforce consistent style with deno fmt and deno lint.
Dependency updates: Periodically update pinned dependencies for improvements or security fixes.
Issue tracking: Treat user feedback seriously. Consider writing a CONTRIBUTING.md for guidelines.
Supabase Edge considerations: Subscribe to Supabase changelogs or Deno Deploy announcements for runtime updates.
Versioning
Use Semantic Versioning (SemVer): MAJOR.MINOR.PATCH.
For initial development, use 0.x versions. Once stable, hit 1.0.0:
- MAJOR: Breaking API changes
- MINOR: New features (backwards compatible)
- PATCH: Bug fixes
Communicate changes: Maintain a CHANGELOG.md listing changes per version.
Deprecation strategy: If the upstream API changes, plan your SDK update. Document which API version your SDK targets.
Testing versions: Test that published packages work before announcing releases.
9. Linting Configuration for Production-Ready SDKs
Building a high-quality Deno SDK requires strict linting rules that enforce best practices. Proper linting produces idiomatic, ergonomic code and maximizes your JSR quality score. Here's a comprehensive lint configuration with explanations.
Base Configuration
Start with Deno's recommended ruleset, layer on the JSR ruleset, then add specific rules for stricter standards:
{
"lint": {
"rules": {
"tags": ["recommended", "jsr"],
"include": [
"explicit-function-return-type",
"explicit-module-boundary-types",
"camelcase",
"single-var-declarator",
"no-console"
]
}
}
}
This enables all recommended rules plus JSR-specific rules, and explicitly includes extra rules for explicit typing, naming, and clean output.
Idiomatic Deno & Cross-Runtime Compatibility
These rules ensure your SDK uses Deno's standard APIs and web-standard globals:
| Rule | Purpose |
|---|---|
no-deprecated-deno-api |
Warns against deprecated Deno APIs for forward compatibility |
no-node-globals |
Disallows Node.js globals (Buffer, global, etc.) |
no-process-global |
Disallows process global—use Deno.env instead |
no-window |
Disallows window object (being removed in Deno 2.0) |
no-window-prefix |
Disallows window. prefix—use globalThis
|
no-var |
Forbids var in favor of let/const
|
prefer-const |
Recommends const for never-reassigned variables |
no-unversioned-import |
Requires explicit version tags in imports |
no-sloppy-imports |
Requires explicit file paths with extensions |
Optional: no-external-import disallows any external URL imports if you vendor all dependencies.
Strict TypeScript Types and Safety
Strong typing is essential for a reliable SDK:
| Rule | Purpose |
|---|---|
no-explicit-any |
Disallows any type entirely—use unknown instead |
no-empty-interface |
Disallows empty interfaces (use Record<string, never> or remove) |
no-inferrable-types |
Prohibits redundant type declarations TypeScript can infer |
explicit-function-return-type |
Requires explicit return types on all functions |
explicit-module-boundary-types |
Requires explicit types on exported symbols |
no-non-null-assertion |
Disallows ! operator—handle undefined safely |
no-non-null-asserted-optional-chain |
Disallows ! after optional chain |
ban-types |
Forbids problematic types like {}, Function, Object
|
use-isnan |
Requires Number.isNaN() instead of === NaN
|
eqeqeq |
Enforces strict equality ===/!==
|
valid-typeof |
Validates typeof comparison strings |
Note: Deno doesn't currently have a no-floating-promises rule. As best practice, manually ensure all promises are awaited or handled.
JSR Compliance and Documentation
The jsr tag enables these crucial rules:
| Rule | Purpose |
|---|---|
no-slow-types |
Ensures types are explicit or simple enough for fast type-checking |
verbatim-module-syntax |
Requires import type for type-only imports |
Documentation practices (not enforced by lint, but crucial):
- Run
deno doc --lintto catch missing JSDoc - Ensure every export has a clear JSDoc comment
- Use
@param,@returns,@throws,@exampletags - Run
deno test --docto verify documentation examples work
Edge Performance Rules
| Rule | Purpose |
|---|---|
no-sync-fn-in-async-fn |
Catches blocking calls in async functions |
require-await |
Flags async functions that don't await anything |
no-console |
Disallows console logging in library code |
Code Style and Maintainability
| Rule | Purpose |
|---|---|
camelcase |
Enforces camelCase naming |
single-var-declarator |
One variable per declaration statement |
ban-untagged-todo |
Requires TODOs to have owner or issue number |
ban-unused-ignore |
Warns about unnecessary lint ignore comments |
ban-ts-comment |
Requires explanation for @ts-ignore comments |
no-useless-rename |
Disallows pointless import/export renames |
adjacent-overload-signatures |
Groups function overloads together |
Optional: prefer-ascii ensures code uses only ASCII characters (useful for preventing homoglyph attacks).
Complete Configuration Example
Here's a full deno.json configuration for a production-ready SDK:
{
"compilerOptions": {
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true
},
"lint": {
"rules": {
"tags": ["recommended", "jsr"],
"include": [
"explicit-function-return-type",
"explicit-module-boundary-types",
"camelcase",
"single-var-declarator",
"no-console",
"ban-untagged-todo"
]
}
},
"fmt": {
"indentWidth": 2,
"lineWidth": 100,
"singleQuote": false,
"proseWrap": "preserve"
},
"test": {
"include": ["**/*_test.ts"]
},
"package": {
"name": "@yourname/my-api-sdk",
"version": "1.0.0",
"description": "Deno SDK for My API",
"license": "MIT",
"exports": "./mod.ts"
}
}
Running Lint
# Run linter
deno lint
# Run linter with auto-fix for fixable issues
deno lint --fix
# Check documentation
deno doc --lint mod.ts
# Format code
deno fmt
# Check formatting without changing files
deno fmt --check
CI Pipeline Example
name: CI
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Check formatting
run: deno fmt --check
- name: Lint
run: deno lint
- name: Check documentation
run: deno doc --lint mod.ts
- name: Test
run: deno test --allow-env --allow-net
- name: Test documentation examples
run: deno test --doc
Summary
By applying these lint rules, your SDK achieves:
- Idiomatic Deno code: No Node globals, no deprecated APIs, no browser-only globals
-
Full type safety: No
any, explicit APIs, complete type declarations - Edge optimization: Non-blocking, minimal overhead, no unnecessary logging
-
Modern patterns: ESM imports,
const/let, proper module syntax - Consistent style: Enforced naming, formatting, and documentation
These rules catch issues at the static analysis stage, leading to fewer bugs and a higher-quality SDK that earns a top JSR score.
Conclusion
You've now built a Deno SDK for a RESTful API tailored for Supabase Edge Functions. We covered:
- Planning a clean project structure with mod.ts entry point
- Handling API keys safely with environment variables
- Writing idiomatic TypeScript with strong types
- Ensuring Deno Deploy compatibility
- Testing thoroughly with unit, integration, and doc tests
- Publishing with JSR best practices
- Configuring linting for production quality
Such an SDK provides a great developer experience: it abstracts HTTP details behind easy-to-use functions, runs anywhere Deno runs (locally or at the edge), and is packaged and documented professionally. By hand-crafting the SDK with strict linting, you avoided code generation pitfalls and created a module that feels native to Deno.
Now developers can import your SDK with a single line and use it inside their Supabase Edge Functions (or any Deno app) to integrate with third-party APIs in a secure and efficient manner.
Happy coding, and may your JSR score be 100%! 🎉
Top comments (0)