React TypeScript File Upload Without a Types Package

Posted on | Last updated on
React TypeScript File Upload Without a Types Package

A React TypeScript file upload used to mean writing your own declarations or installing a community @types package that drifted from the library. Since v7 the Filestack React SDK ships its own .d.ts files, so PickerResponse, PickerFileMetadata and FilestackError come from the package itself and the compiler checks your handlers.

Everything here is in filestack-snippets, which typechecks under TypeScript 5.9 in strict mode and builds on Vite 7. The declarations ship inside the package as of the rewrite described in the React SDK v7.0.0 notes.

Key takeaways

  • v7 ships its own declarations, so no @types package and no declare module shim.
  • Install filestack-js too, since the re-exported type names resolve through it.
  • A discriminated union for upload state makes impossible combinations uncompilable.
  • Annotate pickerOptions explicitly, or a typo passes as an ignored extra property.
  • Types describe the transaction, not the file, so content checks still happen server-side.

Setting up

npm install filestack-react@^7.0.1 filestack-js@^4.0.1

filestack-js is a peer dependency in v7, and for typing that matters more than it does for bundle size. The type names the React package re-exports are defined in filestack-js, so both packages installed means one set of declarations, with no duplicate-identity errors from two copies at different versions.

If you are upgrading, delete anything like this from your project:

// no longer needed, and now actively harmful
declare module 'filestack-react';

A declare module with no body types the whole package as any. Left in place after the upgrade it silently switches off every check this article is about.

The types you actually use

Four names cover almost all of it.

import type {
  PickerResponse,      // what onUploadDone receives
  PickerFileMetadata,  // one file inside that response
  PickerOptions,       // what you pass as pickerOptions
  FilestackError,      // what onError receives
} from 'filestack-react';

PickerResponse is the one that shapes your code:

interface PickerResponse {
  filesUploaded: PickerFileMetadata[];
  filesFailed: PickerFileMetadata[];
}

Two arrays, and both need handling. The compiler will not force you to read the second one, so this is not a class of bug that types delete. What they change is visibility. filesFailed sits in the hover, in autocomplete and in the object you destructure. The untyped version of the same partial upload is walked through in a simpler React upload walkthrough.

PickerFileMetadata carries handle, filename, mimetype, size, url and the source the file came from. The handle is the field worth storing, since it addresses the file on the CDN on its own.

Typing the upload state

The useful pattern here is a discriminated union rather than three loose booleans, because it makes the impossible states unrepresentable.

type UploadState =
  | { status: 'idle' }
  | { status: 'done'; files: PickerFileMetadata[] }
  | { status: 'error'; message: string };

With that, state.files only exists after you have narrowed to status: 'done', so a render path that reads the file list before the upload finished will not compile. Compare it with { loading: boolean; error?: string; files?: File[] }, which lets you write a component that is loading and errored and finished at once.

The component follows straight from it:

// src/examples/TypedCallbacks.tsx
import { useState } from 'react';
import { PickerOverlay } from 'filestack-react';
import type { PickerResponse, FilestackError } from 'filestack-react';

export default function TypedUpload() {
  const [state, setState] = useState<UploadState>({ status: 'idle' });
  const [open, setOpen] = useState(false);

  const onUploadDone = (result: PickerResponse): void => {
    setState({ status: 'done', files: result.filesUploaded });
    setOpen(false);
  };

  const onError = (error: FilestackError | Error): void => {
    setState({ status: 'error', message: error.message });
    setOpen(false);
  };

  return (
    <>
      <button onClick={() => setOpen(true)}>Upload</button>
      {open && <PickerOverlay onUploadDone={onUploadDone} onError={onError} />}

      {state.status === 'error' && <p role="alert">{state.message}</p>}
      {state.status === 'done' && (
        <ul>
          {state.files.map((file) => (
            <li key={file.handle}>{file.filename}</li>
          ))}
        </ul>
      )}
    </>
  );
}

onError is typed FilestackError | Error rather than any, which is why reading error.message is safe and reading error.code is a compile error until you narrow first.

The typed result table populated from a real PickerResponse
The typed result table populated from a real PickerResponse

 

Typing the options

PickerOptions is worth annotating explicitly even though it would be inferred, because the annotation is what turns a typo into a build failure.

// src/filestack.ts
import type { PickerOptions } from 'filestack-react';

export const basePickerOptions: PickerOptions = {
  accept: ['image/*', 'application/pdf'],
  maxFiles: 5,
  fromSources: ['local_file_system', 'url'],
};

Without the annotation, maxFile: 5 is a valid object with an extra property and the picker quietly ignores it. With it, the excess property check rejects the object at the point you wrote it.

accept takes MIME types and extensions. fromSources is typed as string[] rather than a union of the source names, so a typo like google-drive compiles cleanly and the picker leaves that source out of the dialog. Check the source names against the options reference.

Sharing configuration with the provider

FilestackProvider takes the same props the pickers do, which means one typed configuration object serves both.

<FilestackProvider
  apikey={import.meta.env.VITE_FILESTACK_API_KEY}
  pickerOptions={basePickerOptions}
  onUploadDone={onUploadDone}
>
  <App />
</FilestackProvider>

Component props win over the provider for scalars, and pickerOptions is shallow-merged with the provider’s as the base. The type that describes this is PickerBaseProps, exported for the cases where you are writing your own wrapper component and want to accept exactly what a picker accepts:

import type { PickerBaseProps } from 'filestack-react';

function BrandedUpload({ pickerOptions, ...rest }: PickerBaseProps) {
  return <PickerOverlay pickerOptions={{ maxFiles: 1, ...pickerOptions }} {...rest} />;
}

Join the Filestack developer community on Discord

The drag and drop variant

A React TypeScript drag and drop file upload needs no extra types, because PickerDropPane is the same props with a different rendering.

import { PickerDropPane } from 'filestack-react';

<div style={{ height: 220, border: '2px dashed #999' }}>
  <PickerDropPane onUploadDone={onUploadDone} pickerOptions={{ maxFiles: 10 }} />
</div>

If you are writing the drop handling yourself instead, the types come from the DOM library rather than from the SDK. DragEvent in React is React.DragEvent<HTMLDivElement>, and its dataTransfer.files is a FileList rather than an array. The DOM events underneath, before any of this is typed, are worked through in the guide to drag and drop in JavaScript uploads.

Why the two-array response shapes everything

Almost every design decision in a typed upload follows from the shape of PickerResponse rather than from TypeScript itself.

A single upload session can partially succeed. Someone selects eight files, three of them exceed the size limit, and the picker finishes. There is no error, because nothing went wrong at the session level, and onError is never called. onUploadDone fires once with five items in filesUploaded and three in filesFailed. If your handler reads only the first array, the user sees a success message, five files appear, and three vanish without a word.

The type makes the second array visible at the point the handler is written, where handling it costs one line.

Treat a non-empty filesFailed as a state your interface has a design for, alongside idle, uploading and done. The union in this article has three arms because the example is small. A production version usually has four, with partial carrying both arrays, and the compiler will then require every render path to say what it shows when some files made it and some did not.

Typing what happens after the upload

The handle is a string. A branded type keeps a filename from being passed to a function that wanted a handle.

type FileHandle = string & { readonly __brand: 'FileHandle' };

const cdnUrl = (handle: FileHandle, task?: string): string =>
  task
    ? `https://cdn.filestackcontent.com/${task}/${handle}`
    : `https://cdn.filestackcontent.com/${handle}`;

Transformations are path segments in front of the handle, which is how React image editing happens without a second SDK, and the return trip is downloading files in React. No API key goes in a delivery URL, because the handle already identifies the application.

Where the SDK types stop and yours begin

The SDK types describe the transaction and nothing past it.

PickerFileMetadata tells you a file exists, what it was called, and how to address it. It says nothing about whether that file belongs to the logged-in user, whether it should replace an existing avatar, or whether the record it attaches to is still there. Those are your domain’s concerns, and threading PickerFileMetadata through the application couples your domain to the SDK’s shape.

Convert at the boundary instead. The handler that receives the picker result should map it into whatever your application already understands, usually within a few lines of the callback:

type Attachment = { handle: string; name: string; ownerId: string };

const toAttachment = (file: PickerFileMetadata, ownerId: string): Attachment => ({
  handle: file.handle,
  name: file.filename,
  ownerId,
});

Now the SDK type appears in one file rather than forty, and an SDK upgrade that changes a field name is a single compile error in a known place instead of a scattered refactor.

Checking it actually typechecks

Add the check to your build rather than trusting the editor:

{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "tsc --noEmit && vite build"
  }
}

The example repo runs exactly this, with strict, noUnusedLocals and noUnusedParameters on. Without strictNullChecks the filesFailed array and a missing handle are both possibly-undefined values the compiler lets through.

Two settings matter for the imports above. moduleResolution should be bundler so the package’s types entry resolves, and verbatimModuleSyntax will require the import type form used throughout this article rather than a plain import.

The compiler errors you are most likely to hit

Four errors come up when a JavaScript integration is converted.

Property ‘files’ does not exist on type ‘UploadState’. The discriminated union is doing its job. You are reading the file list on a branch where the status has not been narrowed to done yet. Add the check rather than reaching for a non-null assertion, because the assertion turns a compile-time guarantee back into a runtime crash.

Type ‘string’ is not assignable to type ‘PickerDisplayMode’. Some option values are unions rather than free strings. Import the enum from filestack-js or let the object literal be inferred in place instead of storing the value in a string variable first, which widens the type before the compiler can check it.

Object literal may only specify known properties. The excess property check found a typo. This is the error the explicit PickerOptions annotation exists to produce. Read the property name against the options reference before changing the type, since the picker would otherwise have ignored the option in silence.

Cannot find module ‘filestack-react’ or its corresponding type declarations. Either filestack-js is missing, since the re-exported type names resolve through it, or moduleResolution is still set to node in a project that needs bundler. The second is common in older codebases that upgraded React and TypeScript without revisiting the compiler options.

Validating files before they upload

Types describe what the picker hands you. They cannot describe what a user will choose.

The accept option filters the file dialog, which is a convenience for honest users and no defence at all. The mimetype on PickerFileMetadata is typed string, and that string originates from the client. A file renamed from .exe to .png will arrive with a plausible type and a correct-looking PickerFileMetadata object, fully typed and completely wrong.

What TypeScript does give you is a place to make the boundary explicit. A validation function that takes PickerFileMetadata and returns a narrowed type documents which fields have been checked and which have not:

type VerifiedImage = PickerFileMetadata & { readonly verified: true };

function verifyServerSide(file: PickerFileMetadata): Promise<VerifiedImage | null> {
  return fetch(`/api/verify/${file.handle}`).then((r) => (r.ok ? { ...file, verified: true } : null));
}

Now a function that only accepts VerifiedImage cannot be called with a raw picker result, and the check happens where it has to happen, which is on a machine the user does not control. Size limits are the one constraint worth also enforcing client-side through maxSize. Rejecting a 2 GB file before the transfer starts is a usability measure rather than a security one.

Questions people ask

Do I still need @types/filestack-react?

No such package should be installed. The declarations ship inside filestack-react from v7 onward, and a stale community types package sitting alongside them will shadow the real ones and produce errors that make no sense against the documentation.

Does this work with strict mode off?

It compiles, but most of the value disappears. Without strictNullChecks the compiler stops distinguishing a missing handle from a present one, which is precisely the class of bug the types were going to catch.

Can I use the picker components in a .jsx file?

Yes. The package works unchanged from JavaScript, and editors that read declaration files will still offer completion and inline documentation. You lose the build-time checking and keep everything else.

How do I type a wrapper component around the picker?

Accept PickerBaseProps and spread it, as in the BrandedUpload wrapper above. That is the exact prop surface the picker components take, so your wrapper stays correct when the SDK adds an option.

Is the React 19 type situation different?

The package supports React 18.3.1 and 19, and the @types/react major version needs to match whichever you are on. Mixing React 19 with @types/react 18 produces JSX errors that name whichever component is being compiled.

Where this sits

Types are the cheapest part of this integration to get right and the easiest to leave quietly broken, because a leftover declare module shim and a mismatched @types/react both fail by saying nothing. Once the compiler is actually checking your handlers, the remaining decisions are runtime ones. Every prop the compiler is checking against is listed on the React file upload SDK page.

 

 

Read More →