---
title: "Upload an Image With TypeScript"
description: "Upload an image with typed TypeScript, narrow unknown JSON safely, and verify the imgd.dev metadata and public URL."
slug: "upload-image-typescript"
date: 2026-08-20
updated: 2026-08-20
last_tested: 2026-08-20
summary: "Model success and API-error JSON, parse the response as unknown, narrow it with type guards, and accept HTTP 200 or 202."
cluster: Languages
intent: how-to
sources:
  - title: "TypeScript handbook on narrowing"
    url: "https://www.typescriptlang.org/docs/handbook/2/narrowing.html"
  - title: "Node.js global fetch and FormData documentation"
    url: "https://nodejs.org/api/globals.html#fetch"
  - title: "Node.js file system promises documentation"
    url: "https://nodejs.org/api/fs.html#fspromisesreadfilepath-options"
  - title: "imgd.dev OpenAPI specification"
    url: "https://imgd.dev/openapi.json"
---

Upload an image with TypeScript by reading the file, adding a typed `Blob` to `FormData` as `file`, and calling `fetch`. Parse JSON as `unknown`. Use type guards before you read fields. Accept HTTP `202` for new bytes and HTTP `200` for deduplicated bytes.

## Before you upload

imgd.dev costs **$1 per GB as a one-time storage purchase**. It has **no free tier**. Every imgd.dev image is public to anyone with its URL. Do not upload a private, confidential, or secret image.

Use Node.js 20 or later and TypeScript 5.8 or later. Load `IMGD_KEY` from a secret manager.

Confirm that the environment has the key without printing it:

```bash
test -n "$IMGD_KEY"
```

## Complete TypeScript example

Save this code as `examples/core/typescript/upload-image.ts`.

```typescript
/// <reference path="./node-runtime.d.ts" />

import { readFile } from "node:fs/promises";
import { basename, extname } from "node:path";

const UPLOAD_URL = "https://imgd.dev/v1/upload";
const METADATA_URL = "https://imgd.dev/v1/images";
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
const REQUEST_TIMEOUT_MS = 60_000;

const MIME_TYPES: Readonly<Record<string, string>> = {
  ".jpg": "image/jpeg",
  ".jpeg": "image/jpeg",
  ".png": "image/png",
  ".gif": "image/gif",
  ".webp": "image/webp",
  ".avif": "image/avif",
};

type UploadStatus = "processing" | "live" | "review" | "blocked" | "error";

type UploadSuccess = {
  hash: string;
  url: string;
  status: UploadStatus;
  mime: string;
  bytes: number;
  width: number | null;
  height: number | null;
  alt_text: string | null;
  unpublish_at: string | null;
  deduplicated?: boolean;
  note: string;
};

type ApiError = {
  error: string;
  fix: string;
};

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isNullableNumber(value: unknown): value is number | null {
  return value === null || typeof value === "number";
}

function isNullableString(value: unknown): value is string | null {
  return value === null || typeof value === "string";
}

function isUploadStatus(value: unknown): value is UploadStatus {
  return value === "processing"
    || value === "live"
    || value === "review"
    || value === "blocked"
    || value === "error";
}

function isUploadSuccess(value: unknown): value is UploadSuccess {
  return isRecord(value)
    && typeof value.hash === "string"
    && typeof value.url === "string"
    && isUploadStatus(value.status)
    && typeof value.mime === "string"
    && typeof value.bytes === "number"
    && isNullableNumber(value.width)
    && isNullableNumber(value.height)
    && isNullableString(value.alt_text)
    && isNullableString(value.unpublish_at)
    && (value.deduplicated === undefined || typeof value.deduplicated === "boolean")
    && typeof value.note === "string";
}

function isApiError(value: unknown): value is ApiError {
  return isRecord(value)
    && typeof value.error === "string"
    && typeof value.fix === "string";
}

async function readJson(response: Response): Promise<unknown> {
  const text = await response.text();
  try {
    return JSON.parse(text) as unknown;
  } catch {
    throw new Error(`The server returned invalid JSON with HTTP ${response.status}.`);
  }
}

function responseError(response: Response, payload: unknown): Error {
  const guidance: Readonly<Record<number, string>> = {
    401: "Load a valid IMGD_KEY from the secret manager.",
    402: "Buy storage or delete unused images, then retry.",
    413: "Reduce the image below 20 MB, then retry.",
    415: "Use JPEG, PNG, GIF, WebP, or AVIF with the correct media type.",
  };

  const errorCode = isApiError(payload) ? payload.error : "unknown_error";
  const fix = isApiError(payload)
    ? payload.fix
    : guidance[response.status] ?? "Read the response and correct the request.";

  return new Error(`Request failed with HTTP ${response.status} (${errorCode}): ${fix}`);
}

async function uploadImage(imagePath: string, apiKey: string): Promise<UploadSuccess> {
  const extension = extname(imagePath).toLowerCase();
  const mediaType = MIME_TYPES[extension];
  if (!mediaType) {
    throw new Error("Use a JPEG, PNG, GIF, WebP, or AVIF file name.");
  }

  const bytes = await readFile(imagePath);
  if (bytes.byteLength === 0) {
    throw new Error("The image file is empty.");
  }
  if (bytes.byteLength > MAX_UPLOAD_BYTES) {
    throw new Error("The image is larger than the 20 MB upload limit.");
  }

  const form = new FormData();
  form.append("file", new Blob([bytes], { type: mediaType }), basename(imagePath));

  const response = await fetch(UPLOAD_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
    },
    body: form,
    signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
  });
  const payload = await readJson(response);

  if (!response.ok) {
    throw responseError(response, payload);
  }
  if (response.status !== 200 && response.status !== 202) {
    throw new Error(`The upload returned unexpected HTTP ${response.status}.`);
  }
  if (!isUploadSuccess(payload)) {
    throw new Error("The upload response does not match the expected success contract.");
  }

  return payload;
}

async function verifyUpload(upload: UploadSuccess, apiKey: string): Promise<void> {
  const metadataResponse = await fetch(`${METADATA_URL}/${upload.hash}`, {
    headers: {
      Authorization: `Bearer ${apiKey}`,
    },
    signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
  });
  const metadata = await readJson(metadataResponse);
  if (!metadataResponse.ok) {
    throw responseError(metadataResponse, metadata);
  }
  if (!isRecord(metadata) || metadata.hash !== upload.hash) {
    throw new Error("The metadata response did not confirm the upload hash.");
  }

  const publicResponse = await fetch(upload.url, {
    signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
  });
  if (!publicResponse.ok) {
    throw new Error(`The public URL returned HTTP ${publicResponse.status}.`);
  }
  const contentType = publicResponse.headers.get("content-type") ?? "";
  if (!contentType.startsWith("image/")) {
    throw new Error(`The public URL returned the unexpected type '${contentType}'.`);
  }
  const publicBytes = await publicResponse.arrayBuffer();
  if (publicBytes.byteLength === 0) {
    throw new Error("The public URL returned an empty image body.");
  }
}

const imagePath = process.argv[2];
const apiKey = process.env.IMGD_KEY;

if (!imagePath) {
  process.stderr.write("Usage: node upload-image.js IMAGE_PATH\n");
  process.exitCode = 64;
} else if (!apiKey) {
  process.stderr.write("Set IMGD_KEY in the environment.\n");
  process.exitCode = 78;
} else {
  try {
    const upload = await uploadImage(imagePath, apiKey);
    await verifyUpload(upload, apiKey);
    process.stdout.write(`${JSON.stringify(upload, null, 2)}\n`);
  } catch (error: unknown) {
    const message = error instanceof Error ? error.message : String(error);
    process.stderr.write(`${message}\n`);
    process.exitCode = 1;
  }
}
```

The repository has no Node.js type package. This small local declaration file covers only the APIs in the example.

Save it as `examples/core/typescript/node-runtime.d.ts`:

```typescript
declare module "node:fs/promises" {
  export function readFile(path: string): Promise<Uint8Array<ArrayBuffer>>;
}

declare module "node:path" {
  export function basename(path: string): string;
  export function extname(path: string): string;
}

declare const process: {
  argv: string[];
  env: Record<string, string | undefined>;
  stderr: {
    write(message: string): boolean;
  };
  stdout: {
    write(message: string): boolean;
  };
  exitCode: number | undefined;
};
```

If your project already uses the official Node.js type package, remove the reference line and this local declaration file.

## Check and run the example

Run the strict type check:

```bash
npx tsc \
  --noEmit \
  --strict \
  --target ES2022 \
  --module NodeNext \
  --moduleResolution NodeNext \
  examples/core/typescript/upload-image.ts
```

Compile to a temporary directory, then run the JavaScript output:

```bash
rm -rf .tmp-typescript
npx tsc \
  --strict \
  --target ES2022 \
  --module NodeNext \
  --moduleResolution NodeNext \
  --outDir .tmp-typescript \
  examples/core/typescript/upload-image.ts
node .tmp-typescript/upload-image.js ./image.png
rm -rf .tmp-typescript
```

The code uses no `any` type for the response contract. Both the success body and error body start as `unknown`.

## Expected JSON

A new image can produce this output after metadata and public URL checks succeed:

```json
{
  "hash": "0000000000000000000000000000000000000000000000000000000000000000",
  "url": "https://i.imgd.dev/i/0000000000000000000000000000000000000000000000000000000000000000",
  "status": "processing",
  "mime": "image/png",
  "bytes": 51200,
  "width": 1280,
  "height": 720,
  "alt_text": null,
  "unpublish_at": null,
  "note": "the url works immediately, serving a blurred placeholder until the moderation check finishes and it sharpens to the real image"
}
```

New bytes return HTTP `202`. Identical bytes return HTTP `200` and can include `deduplicated: true`.

The public URL works immediately. It serves a blurred placeholder while the status is `processing`.

## Why the type guards matter

`JSON.parse` cannot prove the server shape. The code converts the parse result to `unknown`, then checks each required property.

`isUploadSuccess` checks nullable dimensions, nullable alt text, terminal status values, and the optional deduplication flag.

`isApiError` requires both `error` and `fix`. The program surfaces the server `fix` value when a request fails.

The program does not use an unchecked type assertion for the success response. A malformed response stops before field use.

## Verify the result

`verifyUpload` requests the owner metadata with the bearer key. It confirms that the returned hash matches the upload.

It then requests the public URL without authentication. It requires HTTP `200`, an `image/*` content type, and a non-empty body.

During `processing`, that body is a blurred placeholder. A later metadata request can report `live`, `review`, `blocked`, or `error`.

## Recover from API errors

The response handler uses the HTTP status, stable error code, and server `fix` text.

| Status | Cause | Action |
| --- | --- | --- |
| `401` | `IMGD_KEY` is absent or invalid. | Load a valid key from the secret manager. Do not print it. |
| `402` | The account is unpaid or lacks quota. | Buy storage or delete unused images. Then retry. |
| `413` | The file exceeds 20 MB. | Resize or compress it below 20 MB. |
| `415` | The media type is unsupported. | Use JPEG, PNG, GIF, WebP, or AVIF with the correct extension. |

Do not retry these responses without a change. You can retry a network timeout with the same bytes. The same bytes return the same hash and URL.

An API error has valid JSON:

```json
{
  "error": "invalid_api_key",
  "fix": "this key is not recognized; create a new account with POST /v1/accounts"
}
```

## Security rules

Keep `IMGD_KEY` in the process environment and outside version control. Never put it in a URL, source file, or normal output.

The program prints only the success response. The response contains a public URL and no key.

Inspect each image before upload. Public storage does not protect private text, customer records, or production values.

## Decide if TypeScript fits

This example fits typed Node.js tools that need explicit response validation. It also fits callers that must surface actionable server errors.

It does not fit private images, access control, unsupported files, images above 20 MB, or free-tier needs. Read the [service evaluation guide](/evaluate.md) first.

## Result

The example narrows unknown JSON into a typed success value, then verifies both metadata and the immediate public image response.

Compare it with the [JavaScript upload guide](/blog/upload-image-javascript-nodejs/) or the [Python requests guide](/blog/upload-image-python-requests/).

Give an agent the [integration procedure](/integrate.md). A person can [start from the home page](/#start).
