Skip to content

TypeScript Interop

Floe compiles to TypeScript, so you can use any existing TypeScript or React library directly. No bindings, no wrappers, no code generation.

Import from npm packages the same way you would in TypeScript:

import { useState, useEffect } from "react"
import { z } from "zod"
import { clsx } from "clsx"

The compiler reads .d.ts type definitions to understand the types of imported values. npm imports are untrusted by default — calls are auto-wrapped in Result<T, Error>.

A package that is not installed is an error

Section titled “A package that is not installed is an error”

Floe looks for the package in node_modules. Two different situations follow, and they get two different answers:

The packageFloe reportsfloe check
is not in node_modulesE013, an error, and the fix to runfails
is installed but ships no declarationsW004, a warning on each callpasses
is installed with declarationsnothingpasses

The editor and the command line give the same answer, so a red line in your editor also fails continuous integration.

E013 names the command that fixes it:

error[E013]: cannot find module `"date-fns"`
help: install the package: `npm install date-fns`. If it ships no type
declarations, also add `npm install --save-dev @types/date-fns`

W004 is different. The package is there, so the code runs, but Floe cannot type the symbol and cannot check the arguments you pass to it. Install the matching @types package to clear it.

What Floe never reports as a missing package

Section titled “What Floe never reports as a missing package”

E013 names a package to install, so Floe stays quiet whenever the specifier is not a package or something other than node_modules resolves it:

SpecifierWhyWhat Floe does
node:fs, fs, fs/promisesa Node builtin. Node supplies the module, so it always resolveswarns W004 when @types/node is missing, and names it in the help. Never an error, so a Bun or Deno project still builds
#lib/helperpackage.json imports resolves itnothing
@app/lib/helpera tsconfig paths aliasnothing. Floe reads the tsconfig next to your source file as well as the one at the project root
src/lib/helpera tsconfig baseUrl pathnothing
anything, under Yarn Plug’n’Playthere is no node_modules to readnothing

A project that has simply not been installed yet still reports E013 on every npm import, because the package really is absent and npm install really is the fix.

All npm imports are untrusted by default. The compiler auto-wraps calls in Result<T, Error>:

import { parseYaml } from "yaml-lib"
// parseYaml is auto-wrapped — returns Result<T, Error>
let result = parseYaml(input)
match result {
Ok(data) -> process(data),
Err(e) -> Console.error(e),
}

Use ? to unwrap the result concisely:

let data = parseYaml(input)? // unwraps or returns Err early

For npm functions known to be safe (like React hooks, utility libraries), mark them with trusted so they can be called directly without Result wrapping:

import trusted { useState, useEffect } from "react"
let (count, setCount) = useState(0) // direct call, no wrapping

You can mark individual functions as trusted from a module:

import { trusted capitalize, fetchData } from "some-lib"
capitalize("hello") // direct call, no wrapping (trusted)
let data = fetchData() // Result<T, Error> — auto-wrapped (untrusted)

Every Floe type declaration has the shape type Name = RHS. For interop with TypeScript libraries, two utility types bridge the structural operators TS uses:

  • OneOf<A, B, ...> compiles to A | B | ...
  • Intersect<A, B, ...> compiles to A & B & ...

Plain aliases and TS utility types (Partial, Pick, ReturnType, …) work directly on the RHS.

Many TypeScript libraries use string literal unions for configuration and options:

// React
type HTMLInputTypeAttribute = "text" | "password" | "email" | "number";
// API clients
type Method = "GET" | "POST" | "PUT" | "DELETE";

In Floe, wrap them in OneOf<>:

typealias HttpMethod = OneOf<"GET", "POST", "PUT", "DELETE">
let describe(method: HttpMethod) -> string = {
match method {
"GET" -> "fetching",
"POST" -> "creating",
"PUT" -> "updating",
"DELETE" -> "removing",
}
}

The match is exhaustive — if you miss a variant, the compiler tells you. The type compiles directly to a TypeScript string union (no tags, no wrapping).

Writing a bare string-literal union (type M = "a" | "b") is a compile error (E201). Top-level | always declares nominal variants in Floe; OneOf<> is how you ask for the structural form.

Alias TypeScript types with plain =:

import { ComponentProps } from "react"
typealias DivProps = ComponentProps<"div">
typealias PartialUser = Partial<User>
typealias UserKeys = Pick<User, OneOf<"name", "email">>

Combine TypeScript types with Intersect<>:

import { tv, VariantProps } from "tailwind-variants"
let cardVariants = tv({ base: "rounded-xl", variants: { size: { sm: "p-2" } } })
typealias CardProps = Intersect<VariantProps<typeof cardVariants>, { className: string }>

For Floe-native record composition, prefer ...Spread in a { } record body:

type CardProps = {
...VariantProps<typeof cardVariants>,
className: string,
}

Use -> for function types. Parameter labels are optional documentation:

import { Request, Response } from "express"
typealias Handler = (req: Request, res: Response) -> Promise<()>

Floe has no null or undefined. When importing from TypeScript, the compiler converts nullable and optional types automatically:

TypeScript typeFloe type
T | nullOption<T>
T | undefinedOption<T>
T | null | undefinedOption<T>
x?: T (function param)x: Option<T> = None
x?: T | nullSettable<T> = Unchanged
anyunknown

Optional parameters (?) become Option<T> with a default of None, so you can omit them when calling:

import { getElementById } from "some-dom-lib"
// .d.ts says: getElementById(id: string): Element | null
// Floe sees: getElementById(id: string) -> Option<Element>
match getElementById("app") {
Some(el) -> render(el),
None -> Console.error("element not found"),
}

React hooks work directly:

import { useState, useEffect, useCallback } from "react"
export let Counter() -> JSX.Element = {
let (count, setCount) = useState(0)
useEffect(() -> {
Console.log("count changed:", count)
}, [count])
<button onClick={() -> setCount(count + 1)}>
{`Count: ${count}`}
</button>
}

Third-party React components work as regular JSX:

import { Button, Dialog } from "@radix-ui/react"
export let MyPage() -> JSX.Element = {
let (open, setOpen) = useState(false)
<div>
<Button onClick={() -> setOpen(true)}>Open</Button>
<Dialog open={open} onOpenChange={setOpen}>
<p>Dialog content</p>
</Dialog>
</div>
}

Browser globals like window, document, navigator, and fetch are available automatically — no imports needed. Floe reads your tsconfig.json to determine which globals exist:

// Browser project (lib includes "DOM")
let url = window.location.href
navigator.clipboard.writeText("hello") |> await
let width = window.innerWidth

For non-browser runtimes, configure compilerOptions.lib and compilerOptions.types in your tsconfig.json:

// Node.js
{ "compilerOptions": { "lib": ["ES2020"], "types": ["node"] } }
// Now process, Buffer, etc. are available
let env = process.env

See Configuration for details.

Floe’s compiled output is standard TypeScript. Your build tool (Vite, Next.js, etc.) processes it like any other .ts file. There is no Floe-specific runtime or framework to install.