Skip to main content

TypeScript Guide

Complete guide to using @codeleap/portals with TypeScript for full type safety.

Basic Type Definition

Define parameter and result types for your portals:

import { modal } from '@codeleap/portals'

// Define parameter types
interface UserModalParams {
userId: string
mode: 'view' | 'edit'
initialData?: {
name: string
email: string
}
}

// Define result type
interface UserModalResult {
saved: boolean
user?: {
id: string
name: string
email: string
}
}

// Create typed modal
const UserModal = modal<UserModalParams, UserModalResult>()
.content((props) => {
// props is fully typed!
const { userId, mode, initialData, request } = props

const handleSave = () => {
request?.resolve({
saved: true,
user: {
id: userId,
name: 'John',
email: 'john@example.com'
}
})
}

return (
<div>
<h2>{mode === 'edit' ? 'Edit' : 'View'} User</h2>
<p>User ID: {userId}</p>
{mode === 'edit' && (
<button onClick={handleSave}>Save</button>
)}
</div>
)
})

// Type-safe usage
async function openUserModal() {
// Autocomplete for params
const result = await UserModal.request({
userId: '123',
mode: 'edit',
initialData: {
name: 'John',
email: 'john@example.com'
}
})

// Autocomplete for result
if (result.saved && result.user) {
console.log('User saved:', result.user.name)
}
}

Generic Type Parameters

Portal factory functions accept up to 3 generic type parameters:

import { modal } from '@codeleap/portals'

// Type signature for factory functions
modal<Params, Result, Metadata>()
drawer<Params, Result, Metadata>()
bottomSheet<Params, Result, Metadata>()

// Example with all available generics
interface MyParams {
id: string
name: string
}

interface MyResult {
success: boolean
}

interface MyMetadata {
category: string
requiresAuth: boolean
}

const TypedModal = modal<MyParams, MyResult, MyMetadata>({
metadata: {
category: 'user',
requiresAuth: true
}
})
.content((props) => {
// Params are typed
const { id, name, request } = props
return <div>Content</div>
})

Note: RefType and WrapperProps are not passed as generics. Instead, they come from global interfaces that you extend via declaration merging (see below).

Declaration Merging for Wrapper Types

To type your wrapper component props and ref, use TypeScript declaration merging to extend the global interfaces:

// types/portals.d.ts (or index.d.ts)
import '@codeleap/portals'

// Extend Modal interfaces
declare module '@codeleap/portals' {
interface ModalWrapperProps {
title?: string
theme?: 'light' | 'dark'
size?: 'sm' | 'md' | 'lg'
showClose?: boolean
}

interface ModalRef {
focus: () => void
scrollToTop: () => void
}

// Extend Drawer interfaces
interface DrawerWrapperProps {
side?: 'left' | 'right'
width?: number
}

interface DrawerRef {
snapTo: (position: number) => void
}

// Extend BottomSheet interfaces
interface BottomSheetWrapperProps {
snapPoints?: number[]
enablePanDownToClose?: boolean
}

interface BottomSheetRef {
snapToIndex: (index: number) => void
close: () => void
expand: () => void
}
}

Now all portals will have these types automatically:

import { modal, drawer, bottomSheet } from '@codeleap/portals'

// No need to pass RefType or WrapperProps generics
const MyModal = modal<{ userId: string }>()
.props({
title: 'User Modal', // Typed from ModalWrapperProps
theme: 'dark', // Typed from ModalWrapperProps
size: 'lg', // Typed from ModalWrapperProps
showClose: true // Typed from ModalWrapperProps
})
.content((props) => {
const { userId, ref } = props

// ref is typed as ModalRef
ref.current?.focus()
ref.current?.scrollToTop()

return <div>User: {userId}</div>
})

const MyDrawer = drawer()
.props({
side: 'left', // Typed from DrawerWrapperProps
width: 300 // Typed from DrawerWrapperProps
})
.content((props) => {
// ref is typed as DrawerRef
props.ref.current?.snapTo(100)
return <nav>Navigation</nav>
})

const MySheet = bottomSheet()
.props({
snapPoints: [0.5, 0.9], // Typed from BottomSheetWrapperProps
enablePanDownToClose: true // Typed from BottomSheetWrapperProps
})
.content((props) => {
// ref is typed as BottomSheetRef
props.ref.current?.snapToIndex(1)
props.ref.current?.expand()
return <div>Sheet content</div>
})

Portal Type Examples

Drawer Example

import { drawer } from '@codeleap/portals'

interface NavigationParams {
currentRoute: string
user: {
name: string
role: 'admin' | 'user'
}
}

const NavigationDrawer = drawer<NavigationParams>()
.content((props) => {
const { currentRoute, user } = props

return (
<nav>
<p>Current: {currentRoute}</p>
<p>User: {user.name} ({user.role})</p>
</nav>
)
})

// Type-safe open
NavigationDrawer.open({
currentRoute: '/home',
user: {
name: 'John',
role: 'admin'
}
})

BottomSheet Example

import { bottomSheet } from '@codeleap/portals'

interface FilterParams {
priceRange: {
min: number
max: number
}
categories: string[]
sortBy: 'price' | 'name' | 'date'
}

interface FilterResult {
applied: boolean
filters?: FilterParams
}

const FilterSheet = bottomSheet<FilterParams, FilterResult>()
.content((props) => {
const { priceRange, categories, sortBy, request } = props

const handleApply = () => {
request?.resolve({
applied: true,
filters: { priceRange, categories, sortBy }
})
}

return (
<div>
<p>Price: ${priceRange.min} - ${priceRange.max}</p>
<p>Categories: {categories.join(', ')}</p>
<p>Sort: {sortBy}</p>
<button onClick={handleApply}>Apply</button>
</div>
)
})

// Type-safe request
const result = await FilterSheet.request({
priceRange: { min: 0, max: 100 },
categories: ['electronics', 'books'],
sortBy: 'price'
})

if (result.applied && result.filters) {
applyFilters(result.filters)
}

Configuration Types

import { modal, PortalConstructorParam } from '@codeleap/portals'

interface MyParams {
userId: string
}

interface MyMetadata {
category: 'user' | 'admin'
version: number
}

// Type for constructor parameter
const config: PortalConstructorParam<MyParams, MyMetadata> = {
id: 'MY_MODAL',
initialParams: {
userId: '123'
},
metadata: {
category: 'user',
version: 1
},
startsOpen: false,
transitionDuration: 300
}

const MyModal = modal<MyParams, any, MyMetadata>(config)

// Access typed config
const metadata: MyMetadata = MyModal._config.metadata
console.log(metadata.category, metadata.version)

Content Props Type

import { RenderPortalContent } from '@codeleap/portals'

interface MyParams {
title: string
items: string[]
}

interface MyResult {
selected: string
}

// Typed render function
const renderContent: RenderPortalContent<MyParams, MyResult, HTMLDivElement> = (props) => {
const { title, items, request, ref } = props

return (
<div ref={ref}>
<h2>{title}</h2>
{items.map(item => (
<button
key={item}
onClick={() => request?.resolve({ selected: item })}
>
{item}
</button>
))}
</div>
)
}

const MyModal = modal<MyParams, MyResult>()
.content(renderContent)

Alert Types

import { Alert, AlertOptions, AlertOption, AlertType } from '@codeleap/portals'

// Type-safe alert options
const options: AlertOption[] = [
{
text: 'Delete',
style: 'destructive',
onPress: () => console.log('Delete')
},
{
text: 'Cancel',
style: 'cancel',
onPress: () => console.log('Cancel')
}
]

const alertConfig: AlertOptions = {
title: 'Confirm Delete',
body: 'Are you sure?',
type: 'warn',
options: options,
onDismiss: () => console.log('Dismissed')
}

// Type-safe alert methods
alert.warn(alertConfig)
alert.error({ title: 'Error', body: 'Something went wrong' })
alert.info({ title: 'Info', body: 'Information message' })

// Custom alert with extra data
interface CustomAlertData extends AlertOptions {
customField: string
customNumber: number
}

const customData: CustomAlertData = {
title: 'Custom',
body: 'Custom alert',
customField: 'value',
customNumber: 42
}

alert.custom<CustomAlertData>(customData)

Hooks Types

import { modal } from '@codeleap/portals'
import { useState as useReactState } from 'react'

interface TodoParams {
todos: Array<{
id: string
text: string
done: boolean
}>
}

const TodoModal = modal<TodoParams>()
.content((props) => {
const { todos } = props
return <div>{todos.length} todos</div>
})

function TodoList() {
// Typed useState hook
const { visible, params } = TodoModal.useState()
// params is typed as TodoParams

const todoCount = params.todos?.length || 0

return (
<div>
<p>Modal is {visible ? 'open' : 'closed'}</p>
<p>Todos: {todoCount}</p>
<button onClick={() => TodoModal.open({
todos: [
{ id: '1', text: 'Learn TypeScript', done: true },
{ id: '2', text: 'Build app', done: false }
]
})}>
Open
</button>
</div>
)
}

Utility Types and Type Inference

import {
Portal,
Modal,
Drawer,
BottomSheet,
PortalState,
PortalRequest,
PortalRegistry
} from '@codeleap/portals'

// Portal state type
type MyPortalState = PortalState<{ userId: string }>

// Portal request type
type MyPortalRequest = PortalRequest<
{ message: string },
{ confirmed: boolean }
>

// Portal registry type
type MyPortalRegistry = PortalRegistry<Modal<any, any>>

// Extract param types from existing portal
const UserModal = modal<{ userId: string, name: string }>()

type UserParams = Parameters<typeof UserModal.open>[0]
// UserParams = { userId: string, name: string } | undefined

type UserOpenReturn = ReturnType<typeof UserModal.open>
// UserOpenReturn = Promise<void>

// Extract result type from request
const ConfirmModal = modal<{ message: string }, boolean>()
type ConfirmResult = Awaited<ReturnType<typeof ConfirmModal.request>>
// ConfirmResult = boolean

Creating Custom Portal Types

To create your own portal type, extend the base Portal class and define custom ref and wrapper prop interfaces:

import { Portal, PortalConstructorParam } from '@codeleap/portals'
import { ReactElement } from 'react'

// Define global interfaces for your custom portal
export interface CustomPortalWrapperProps {
customProp: string
theme?: 'light' | 'dark'
}

export interface CustomPortalRef {
customMethod: () => void
reset: () => void
}

// Extend Portal with your custom types
class CustomPortal<
Params = {},
Result = {},
Metadata = {}
> extends Portal<Params, Result, Metadata, CustomPortalRef, CustomPortalWrapperProps> {
displayName = 'CustomPortal'

static WrapperComponent: (props: any) => ReactElement = () => null
static DEFAULT_TRANSITION_DURATION = 300

protected get defaultTransitionDuration() {
return CustomPortal.DEFAULT_TRANSITION_DURATION
}

protected get wrapperComponent() {
return CustomPortal.WrapperComponent
}

// Add custom methods to your portal
customAction(data: string): void {
console.log('Custom action:', data)
this.setParams({ customData: data } as any)
}
}

// Factory function
export function customPortal<
Params = {},
Result = {},
Metadata = {}
>(
idOrConfig?: PortalConstructorParam<Params, Metadata>
): CustomPortal<Params, Result, Metadata> {
return new CustomPortal<Params, Result, Metadata>(idOrConfig)
}

// Usage
interface MyParams {
name: string
customData?: string
}

interface MyResult {
success: boolean
}

const MyCustomPortal = customPortal<MyParams, MyResult>({ id: 'CUSTOM' })
.props({
customProp: 'value', // Typed from CustomPortalWrapperProps
theme: 'dark' // Typed from CustomPortalWrapperProps
})
.content((props) => {
const { name, ref } = props

// ref is typed as CustomPortalRef
ref.current?.customMethod()
ref.current?.reset()

return <div>{name}</div>
})

MyCustomPortal.customAction('test') // Custom method

Type Guards and Narrowing

import { modal } from '@codeleap/portals'

interface BaseParams {
type: 'simple' | 'complex'
}

interface SimpleParams extends BaseParams {
type: 'simple'
message: string
}

interface ComplexParams extends BaseParams {
type: 'complex'
data: {
items: string[]
count: number
}
}

type UnionParams = SimpleParams | ComplexParams

const FlexibleModal = modal<UnionParams>()
.content((props) => {
// Type narrowing
if (props.type === 'simple') {
// props is SimpleParams here
return <div>{props.message}</div>
} else {
// props is ComplexParams here
return (
<div>
<p>Count: {props.data.count}</p>
<ul>
{props.data.items.map(item => (
<li key={item}>{item}</li>
))}
</ul>
</div>
)
}
})

// Type-safe calls
FlexibleModal.open({
type: 'simple',
message: 'Hello'
})

FlexibleModal.open({
type: 'complex',
data: {
items: ['a', 'b', 'c'],
count: 3
}
})

Async Parameter Types

import { modal } from '@codeleap/portals'

interface UserData {
id: string
name: string
email: string
}

interface AsyncParams {
userData: UserData
permissions: string[]
}

const AsyncModal = modal<AsyncParams>({
// Typed async initial params
initialParams: async (): Promise<AsyncParams> => {
const userData = await fetch('/api/user').then(r => r.json() as Promise<UserData>)
const permissions = await fetch('/api/permissions').then(r => r.json() as Promise<string[]>)

return {
userData,
permissions
}
}
})
.content((props) => {
const { userData, permissions } = props

return (
<div>
<h2>{userData.name}</h2>
<p>{userData.email}</p>
<p>Permissions: {permissions.join(', ')}</p>
</div>
)
})