Skip to main content
On this pageOverview

File

Overview

The File module wraps the browser file APIs as Effects you can run from a Command. It mirrors the design of Elm’s elm/file package: file values are opaque, file selection happens imperatively through a Command (not a form event), and file contents are read asynchronously via FileReader.

A File is a direct alias for the browser’s native File type. You can hold one in your Model with S.OptionFromSelf(File.File) — Foldkit never serializes files, so the schema acts as an opaque guard rather than a parser.

Metadata and reading

File.name, File.size, and File.mimeType return metadata synchronously. File.readAsText, File.readAsDataUrl, and File.readAsArrayBuffer wrap the browser’s FileReader as Effects that can fail with a FileReadError. Use readAsDataUrl when you want a preview thumbnail without uploading the file first.

import { Effect } from 'effect'
import { Command, File } from 'foldkit'

const describeFile = (file: File.File): string =>
  `${File.name(file)} (${File.mimeType(file)}, ${File.size(file)} bytes)`

const ReadAvatarPreview = Command.define(
  'ReadAvatarPreview',
  SucceededReadAvatarPreview,
  FailedReadAvatarPreview,
)

const readAvatarPreview = (file: File.File) =>
  ReadAvatarPreview(
    File.readAsDataUrl(file).pipe(
      Effect.map(dataUrl => SucceededReadAvatarPreview({ dataUrl })),
      Effect.catchAll(error =>
        Effect.succeed(FailedReadAvatarPreview({ reason: error.reason })),
      ),
    ),
  )

Selecting files

File.select and File.selectMultiple open the native file picker and resolve with the files the user chose. Both take a list of accepted MIME types or extensions and resolve with an empty array if the user cancels. Mirrors Elm’s File.Select.file and File.Select.files.

Wrap the Effect in a Command at the call site with Effect.map to produce your own Message — the File module never defines Messages, so you keep full control of your domain vocabulary.

import { Effect } from 'effect'
import { Command, File } from 'foldkit'

const SelectResume = Command.define('SelectResume', SelectedResume)

const selectResume = SelectResume(
  File.select(['application/pdf']).pipe(
    Effect.map(files => SelectedResume({ files })),
  ),
)

const SelectAttachments = Command.define(
  'SelectAttachments',
  SelectedAttachments,
)

const selectAttachments = SelectAttachments(
  File.selectMultiple(['image/*', 'application/pdf']).pipe(
    Effect.map(files => SelectedAttachments({ files })),
  ),
)

Form event attributes

Two event attributes in the foldkit/html module let you read files from form inputs and drag-and-drop zones. OnFileChange decodes event.target.files on an <input type="file"> and resets the input value so the same file can be selected twice in a row. OnDropFiles decodes event.dataTransfer.files on a drop event and calls preventDefault for you.

Drop zones still need OnDragOver to enable dropping in the browser, and you can use OnDragEnter/OnDragLeave for visual feedback.

import { File } from 'foldkit'
import { html } from 'foldkit/html'

const {
  div,
  input,
  Class,
  Key,
  Type,
  Accept,
  Multiple,
  OnFileChange,
  OnDragOver,
  OnDragEnter,
  OnDragLeave,
  OnDropFiles,
} = html<Message>()

const resumePicker = (model: Model) =>
  input([
    Key('resume-input'),
    Type('file'),
    Accept('application/pdf'),
    OnFileChange(files => SelectedResume({ files })),
  ])

const attachmentsDropZone = (model: Model) =>
  div(
    [
      Key('attachments-drop-zone'),
      Class(
        model.isDraggingOver
          ? 'border-2 border-dashed border-accent-500 bg-accent-50'
          : 'border-2 border-dashed border-gray-300',
      ),
      OnDragEnter(StartedDragOver()),
      OnDragLeave(StoppedDragOver()),
      OnDragOver(StartedDragOver()),
      OnDropFiles(files => DroppedAttachments({ files })),
    ],
    ['Drop files here, or use the Browse button below.'],
  )

Testing

Scene tests exercise OnFileChange and OnDropFiles through two dedicated helpers: Scene.changeFiles dispatches a synthetic change event on a file input, and Scene.dropFiles dispatches a synthetic drop event on a drop zone. Both accept a target locator and a ReadonlyArray<File>. Both helpers throw a clear error if the target element’s change or drop handler was not registered via OnFileChange or OnDropFiles — this prevents silent misuse against elements that use OnChange or OnDrop instead.

For button-triggered pickers that use the File.select Command, scene tests use Scene.click on the button and then Scene.resolve to synthesize the result, bypassing the native file picker entirely. Use Scene.resolveAll when an update returns multiple Commands at once, or when resolving one Command cascades into others — for example, reading a preview immediately after a successful selection.

import { Scene } from 'foldkit'
import { describe, test } from 'vitest'

describe('resume upload flow', () => {
  const resume = new File(['%PDF-'], 'resume.pdf', {
    type: 'application/pdf',
  })

  test('inline file input: changeFiles simulates selection', () => {
    Scene.scene(
      { update, view },
      Scene.with(initialModel),
      Scene.changeFiles(Scene.label('resume'), [resume]),
      Scene.expect(Scene.text('resume.pdf')).toExist(),
    )
  })

  test('button-triggered picker: resolve the SelectResume Command', () => {
    const previewDataUrl = 'data:application/pdf;base64,JVBERi0='

    Scene.scene(
      { update, view },
      Scene.with(initialModel),
      Scene.click(Scene.role('button', { name: 'Choose resume' })),
      Scene.resolveAll(
        [SelectResume, SelectedResume({ files: [resume] })],
        [ReadResumePreview, SucceededReadPreview({ dataUrl: previewDataUrl })],
      ),
      Scene.expect(Scene.role('img', { name: 'Resume preview' })).toExist(),
    )
  })

  test('drop zone: dropFiles simulates a drag-and-drop', () => {
    const coverLetter = new File(['cover'], 'cover.txt', {
      type: 'text/plain',
    })
    const portfolio = new File(['<svg/>'], 'portfolio.svg', {
      type: 'image/svg+xml',
    })

    Scene.scene(
      { update, view },
      Scene.with(initialModel),
      Scene.dropFiles(Scene.label('attachments'), [coverLetter, portfolio]),
      Scene.expect(Scene.text('2 attachments selected')).toExist(),
    )
  })
})

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson