fp-ts-validation

fp-ts Validation Patterns

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "fp-ts-validation" with this command: npx skills add whatiskadudoing/fp-ts-skills/whatiskadudoing-fp-ts-skills-fp-ts-validation

fp-ts Validation Patterns

This skill covers validation patterns using fp-ts, focusing on error accumulation, form validation, and API input validation.

Core Concepts

Either for Validation

Either<E, A> represents a computation that can fail with error E or succeed with value A .

import * as E from 'fp-ts/Either' import { pipe } from 'fp-ts/function'

// Basic validation function signature type Validation<E, A> = E.Either<E, A>

// Simple validation returning Either const validateNonEmpty = (field: string) => (value: string): E.Either<string, string> => value.trim().length > 0 ? E.right(value.trim()) : E.left(${field} cannot be empty)

const validateMinLength = (field: string, min: number) => (value: string): E.Either<string, string> => value.length >= min ? E.right(value) : E.left(${field} must be at least ${min} characters)

const validateEmail = (email: string): E.Either<string, string> => /^[^\s@]+@[^\s@]+.[^\s@]+$/.test(email) ? E.right(email) : E.left('Invalid email format')

Fail-Fast vs Error Accumulation

Fail-Fast Pattern (chain/flatMap)

Stops at the first error encountered. Use when subsequent validations depend on previous ones.

import * as E from 'fp-ts/Either' import { pipe } from 'fp-ts/function'

// Fail-fast: stops at first error const validateUserFailFast = (input: { name: string; email: string }) => pipe( validateNonEmpty('name')(input.name), E.chain(name => pipe( validateEmail(input.email), E.map(email => ({ name, email })) )) )

// Usage validateUserFailFast({ name: '', email: 'invalid' }) // Result: Left('name cannot be empty') - email error not reported

Error Accumulation Pattern

Collects ALL errors using Applicative with a Semigroup for combining errors.

import * as E from 'fp-ts/Either' import * as A from 'fp-ts/Apply' import * as NEA from 'fp-ts/NonEmptyArray' import { pipe } from 'fp-ts/function'

// Define error type as NonEmptyArray for accumulation type ValidationErrors = NEA.NonEmptyArray<string>

// Lift validation to use NonEmptyArray for errors const validateNonEmptyV = (field: string) => (value: string): E.Either<ValidationErrors, string> => value.trim().length > 0 ? E.right(value.trim()) : E.left(NEA.of(${field} cannot be empty))

const validateEmailV = (email: string): E.Either<ValidationErrors, string> => /^[^\s@]+@[^\s@]+.[^\s@]+$/.test(email) ? E.right(email) : E.left(NEA.of('Invalid email format'))

const validateMinLengthV = (field: string, min: number) => (value: string): E.Either<ValidationErrors, string> => value.length >= min ? E.right(value) : E.left(NEA.of(${field} must be at least ${min} characters))

// Get applicative that accumulates errors const applicativeValidation = E.getApplicativeValidation(NEA.getSemigroup<string>())

Using sequenceT and sequenceS

sequenceT - Tuple-based Combining

Combines validations into a tuple, accumulating all errors.

import { sequenceT } from 'fp-ts/Apply'

// Combine multiple validations with error accumulation const validateUserWithSequenceT = (input: { name: string; email: string; age: string }) => { const validateAge = (age: string): E.Either<ValidationErrors, number> => { const parsed = parseInt(age, 10) return isNaN(parsed) || parsed < 0 || parsed > 150 ? E.left(NEA.of('Age must be a valid number between 0 and 150')) : E.right(parsed) }

return pipe( sequenceT(applicativeValidation)( validateNonEmptyV('name')(input.name), validateEmailV(input.email), validateAge(input.age) ), E.map(([name, email, age]) => ({ name, email, age })) ) }

// Usage - collects ALL errors validateUserWithSequenceT({ name: '', email: 'invalid', age: '-5' }) // Result: Left(['name cannot be empty', 'Invalid email format', 'Age must be a valid number between 0 and 150'])

sequenceS - Struct-based Combining

Combines validations into a struct/object, preserving field names.

import { sequenceS } from 'fp-ts/Apply'

interface UserInput { name: string email: string password: string }

interface ValidatedUser { name: string email: string password: string }

const validateUserWithSequenceS = (input: UserInput): E.Either<ValidationErrors, ValidatedUser> => sequenceS(applicativeValidation)({ name: validateNonEmptyV('name')(input.name), email: validateEmailV(input.email), password: pipe( validateNonEmptyV('password')(input.password), E.chain(p => validateMinLengthV('password', 8)(p)) ) })

// Usage validateUserWithSequenceS({ name: 'John', email: 'invalid', password: '123' }) // Result: Left(['Invalid email format', 'password must be at least 8 characters'])

Form Validation with Error Accumulation

Complete Form Validation Example

import * as E from 'fp-ts/Either' import * as A from 'fp-ts/Apply' import * as NEA from 'fp-ts/NonEmptyArray' import { sequenceS } from 'fp-ts/Apply' import { pipe } from 'fp-ts/function'

// Custom error type with field information interface FieldError { field: string message: string }

type FormErrors = NEA.NonEmptyArray<FieldError>

const fieldError = (field: string, message: string): FormErrors => NEA.of({ field, message })

const formApplicative = E.getApplicativeValidation(NEA.getSemigroup<FieldError>())

// Reusable validators const required = (field: string) => (value: string): E.Either<FormErrors, string> => value.trim().length > 0 ? E.right(value.trim()) : E.left(fieldError(field, 'This field is required'))

const minLength = (field: string, min: number) => (value: string): E.Either<FormErrors, string> => value.length >= min ? E.right(value) : E.left(fieldError(field, Must be at least ${min} characters))

const maxLength = (field: string, max: number) => (value: string): E.Either<FormErrors, string> => value.length <= max ? E.right(value) : E.left(fieldError(field, Must be at most ${max} characters))

const pattern = (field: string, regex: RegExp, message: string) => (value: string): E.Either<FormErrors, string> => regex.test(value) ? E.right(value) : E.left(fieldError(field, message))

const email = (field: string) => pattern(field, /^[^\s@]+@[^\s@]+.[^\s@]+$/, 'Invalid email format')

const numeric = (field: string) => (value: string): E.Either<FormErrors, number> => { const num = parseFloat(value) return isNaN(num) ? E.left(fieldError(field, 'Must be a valid number')) : E.right(num) }

const inRange = (field: string, min: number, max: number) => (value: number): E.Either<FormErrors, number> => value >= min && value <= max ? E.right(value) : E.left(fieldError(field, Must be between ${min} and ${max}))

// Compose validators for a single field (fail-fast within field) const composeValidators = <A, B>( v1: (a: A) => E.Either<FormErrors, B>, ...validators: Array<(b: B) => E.Either<FormErrors, B>> ) => (value: A): E.Either<FormErrors, B> => validators.reduce( (acc, validator) => pipe(acc, E.chain(validator)), v1(value) )

// Registration form validation interface RegistrationInput { username: string email: string password: string confirmPassword: string age: string }

interface ValidatedRegistration { username: string email: string password: string age: number }

const validateRegistration = (input: RegistrationInput): E.Either<FormErrors, ValidatedRegistration> => { const validateUsername = composeValidators( required('username'), minLength('username', 3), maxLength('username', 20), pattern('username', /^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores allowed') )

const validateEmail = composeValidators( required('email'), email('email') )

const validatePassword = composeValidators( required('password'), minLength('password', 8), pattern('password', /[A-Z]/, 'Must contain at least one uppercase letter'), pattern('password', /[a-z]/, 'Must contain at least one lowercase letter'), pattern('password', /[0-9]/, 'Must contain at least one number') )

const validateAge = composeValidators( required('age'), numeric('age'), inRange('age', 13, 120) )

// Cross-field validation const validatePasswordMatch = (): E.Either<FormErrors, void> => input.password === input.confirmPassword ? E.right(undefined) : E.left(fieldError('confirmPassword', 'Passwords do not match'))

return pipe( sequenceS(formApplicative)({ username: validateUsername(input.username), email: validateEmail(input.email), password: validatePassword(input.password), age: validateAge(input.age), _passwordMatch: validatePasswordMatch() }), E.map(({ username, email, password, age }) => ({ username, email, password, age })) ) }

// Usage const result = validateRegistration({ username: 'ab', email: 'invalid', password: 'weak', confirmPassword: 'different', age: '10' })

// Result: Left([ // { field: 'username', message: 'Must be at least 3 characters' }, // { field: 'email', message: 'Invalid email format' }, // { field: 'password', message: 'Must be at least 8 characters' }, // { field: 'age', message: 'Must be between 13 and 120' }, // { field: 'confirmPassword', message: 'Passwords do not match' } // ])

// Helper to convert errors to a field-indexed object const errorsToRecord = (errors: FormErrors): Record<string, string[]> => errors.reduce((acc, err) => ({ ...acc, [err.field]: [...(acc[err.field] || []), err.message] }), {} as Record<string, string[]>)

// For React forms const getFieldErrors = (result: E.Either<FormErrors, unknown>) => (field: string): string[] => pipe( result, E.fold( errors => errors.filter(e => e.field === field).map(e => e.message), () => [] ) )

API Input Validation

Request Body Validation

import * as E from 'fp-ts/Either' import * as NEA from 'fp-ts/NonEmptyArray' import { sequenceS } from 'fp-ts/Apply' import { pipe } from 'fp-ts/function'

// API error types interface ApiValidationError { code: string field: string message: string }

type ApiErrors = NEA.NonEmptyArray<ApiValidationError>

const apiError = (code: string, field: string, message: string): ApiErrors => NEA.of({ code, field, message })

const apiApplicative = E.getApplicativeValidation(NEA.getSemigroup<ApiValidationError>())

// Common API validators const requiredField = <T>(field: string) => (value: T | null | undefined): E.Either<ApiErrors, T> => value != null ? E.right(value) : E.left(apiError('REQUIRED', field, ${field} is required))

const stringField = (field: string) => (value: unknown): E.Either<ApiErrors, string> => typeof value === 'string' ? E.right(value) : E.left(apiError('INVALID_TYPE', field, ${field} must be a string))

const numberField = (field: string) => (value: unknown): E.Either<ApiErrors, number> => typeof value === 'number' && !isNaN(value) ? E.right(value) : E.left(apiError('INVALID_TYPE', field, ${field} must be a number))

const arrayField = <T>(field: string) => (value: unknown): E.Either<ApiErrors, T[]> => Array.isArray(value) ? E.right(value as T[]) : E.left(apiError('INVALID_TYPE', field, ${field} must be an array))

const uuidField = (field: string) => (value: string): E.Either<ApiErrors, string> => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) ? E.right(value) : E.left(apiError('INVALID_FORMAT', field, ${field} must be a valid UUID))

const enumField = <T extends string>(field: string, allowed: readonly T[]) => (value: string): E.Either<ApiErrors, T> => allowed.includes(value as T) ? E.right(value as T) : E.left(apiError('INVALID_VALUE', field, ${field} must be one of: ${allowed.join(', ')}))

// Create Order API validation interface CreateOrderRequest { customerId: string items: Array<{ productId: string quantity: number }> shippingAddress: { street: string city: string postalCode: string country: string } paymentMethod: string }

interface ValidatedOrder { customerId: string items: Array<{ productId: string; quantity: number }> shippingAddress: { street: string city: string postalCode: string country: string } paymentMethod: 'credit_card' | 'paypal' | 'bank_transfer' }

const validateOrderItem = (item: unknown, index: number): E.Either<ApiErrors, { productId: string; quantity: number }> => { if (typeof item !== 'object' || item === null) { return E.left(apiError('INVALID_TYPE', items[${index}], 'Item must be an object')) }

const obj = item as Record<string, unknown>

return sequenceS(apiApplicative)({ productId: pipe( requiredField<unknown>(items[${index}].productId)(obj.productId), E.chain(stringField(items[${index}].productId)), E.chain(uuidField(items[${index}].productId)) ), quantity: pipe( requiredField<unknown>(items[${index}].quantity)(obj.quantity), E.chain(numberField(items[${index}].quantity)), E.chain(n => n > 0 ? E.right(n) : E.left(apiError('INVALID_VALUE', items[${index}].quantity, 'Quantity must be positive')) ) ) }) }

const validateAddress = (address: unknown): E.Either<ApiErrors, ValidatedOrder['shippingAddress']> => { if (typeof address !== 'object' || address === null) { return E.left(apiError('INVALID_TYPE', 'shippingAddress', 'Shipping address must be an object')) }

const obj = address as Record<string, unknown>

return sequenceS(apiApplicative)({ street: pipe( requiredField<unknown>('shippingAddress.street')(obj.street), E.chain(stringField('shippingAddress.street')) ), city: pipe( requiredField<unknown>('shippingAddress.city')(obj.city), E.chain(stringField('shippingAddress.city')) ), postalCode: pipe( requiredField<unknown>('shippingAddress.postalCode')(obj.postalCode), E.chain(stringField('shippingAddress.postalCode')) ), country: pipe( requiredField<unknown>('shippingAddress.country')(obj.country), E.chain(stringField('shippingAddress.country')) ) }) }

const validateCreateOrder = (body: unknown): E.Either<ApiErrors, ValidatedOrder> => { if (typeof body !== 'object' || body === null) { return E.left(apiError('INVALID_TYPE', 'body', 'Request body must be an object')) }

const obj = body as Record<string, unknown>

// Validate items array with accumulation const validateItems = (items: unknown[]): E.Either<ApiErrors, Array<{ productId: string; quantity: number }>> => { if (items.length === 0) { return E.left(apiError('INVALID_VALUE', 'items', 'Order must contain at least one item')) }

const validatedItems = items.map((item, index) => validateOrderItem(item, index))

// Accumulate all item errors
return validatedItems.reduce(
  (acc, itemResult) => pipe(
    sequenceS(apiApplicative)({ acc, item: itemResult }),
    E.map(({ acc, item }) => [...acc, item])
  ),
  E.right([]) as E.Either&#x3C;ApiErrors, Array&#x3C;{ productId: string; quantity: number }>>
)

}

return pipe( sequenceS(apiApplicative)({ customerId: pipe( requiredField<unknown>('customerId')(obj.customerId), E.chain(stringField('customerId')), E.chain(uuidField('customerId')) ), items: pipe( requiredField<unknown>('items')(obj.items), E.chain(arrayField<unknown>('items')), E.chain(validateItems) ), shippingAddress: pipe( requiredField<unknown>('shippingAddress')(obj.shippingAddress), E.chain(validateAddress) ), paymentMethod: pipe( requiredField<unknown>('paymentMethod')(obj.paymentMethod), E.chain(stringField('paymentMethod')), E.chain(enumField('paymentMethod', ['credit_card', 'paypal', 'bank_transfer'] as const)) ) }) ) }

// Express middleware example const validateRequest = <T>(validator: (body: unknown) => E.Either<ApiErrors, T>) => (req: { body: unknown }, res: { status: (n: number) => { json: (body: unknown) => void } }, next: () => void) => { pipe( validator(req.body), E.fold( errors => res.status(400).json({ error: 'Validation failed', details: errors }), validated => { (req as any).validatedBody = validated next() } ) ) }

Custom Error Types

Branded Error Types

import * as E from 'fp-ts/Either' import * as NEA from 'fp-ts/NonEmptyArray'

// Domain-specific error types type ValidationErrorCode = | 'REQUIRED' | 'MIN_LENGTH' | 'MAX_LENGTH' | 'PATTERN' | 'RANGE' | 'INVALID_TYPE' | 'BUSINESS_RULE'

interface DomainValidationError { readonly _tag: 'ValidationError' readonly code: ValidationErrorCode readonly field: string readonly message: string readonly metadata?: Record<string, unknown> }

const validationError = ( code: ValidationErrorCode, field: string, message: string, metadata?: Record<string, unknown> ): DomainValidationError => ({ _tag: 'ValidationError', code, field, message, metadata })

type DomainErrors = NEA.NonEmptyArray<DomainValidationError>

// Helper to create single error const singleError = ( code: ValidationErrorCode, field: string, message: string, metadata?: Record<string, unknown> ): DomainErrors => NEA.of(validationError(code, field, message, metadata))

// Validators with rich error metadata const minLengthWithMeta = (field: string, min: number) => (value: string): E.Either<DomainErrors, string> => value.length >= min ? E.right(value) : E.left(singleError('MIN_LENGTH', field, Must be at least ${min} characters, { actual: value.length, required: min }))

Integration with io-ts

Basic io-ts Integration

import * as t from 'io-ts' import * as E from 'fp-ts/Either' import * as NEA from 'fp-ts/NonEmptyArray' import { pipe } from 'fp-ts/function' import { PathReporter } from 'io-ts/PathReporter'

// Define codec with io-ts const UserCodec = t.type({ name: t.string, email: t.string, age: t.number, role: t.union([t.literal('admin'), t.literal('user'), t.literal('guest')]) })

type User = t.TypeOf<typeof UserCodec>

// Convert io-ts errors to our error format interface IoTsValidationError { field: string message: string }

const fromIoTsErrors = (errors: t.Errors): NEA.NonEmptyArray<IoTsValidationError> => { const mapped = errors.map(error => ({ field: error.context.map(c => c.key).filter(k => k !== '').join('.') || 'root', message: Invalid value ${JSON.stringify(error.value)} supplied })) return mapped as NEA.NonEmptyArray<IoTsValidationError> }

// Decode with accumulated errors const decodeUser = (input: unknown): E.Either<NEA.NonEmptyArray<IoTsValidationError>, User> => pipe( UserCodec.decode(input), E.mapLeft(fromIoTsErrors) )

// Combine io-ts decoding with custom validation const validateAndDecodeUser = (input: unknown): E.Either<NEA.NonEmptyArray<IoTsValidationError>, User> => pipe( decodeUser(input), E.chain(user => { const errors: IoTsValidationError[] = []

  if (user.name.length &#x3C; 2) {
    errors.push({ field: 'name', message: 'Name must be at least 2 characters' })
  }
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(user.email)) {
    errors.push({ field: 'email', message: 'Invalid email format' })
  }
  if (user.age &#x3C; 0 || user.age > 150) {
    errors.push({ field: 'age', message: 'Age must be between 0 and 150' })
  }

  return errors.length > 0
    ? E.left(errors as NEA.NonEmptyArray&#x3C;IoTsValidationError>)
    : E.right(user)
})

)

Custom io-ts Codecs with Validation

import * as t from 'io-ts' import * as E from 'fp-ts/Either'

// Custom branded types with validation interface EmailBrand { readonly Email: unique symbol } type Email = t.Branded<string, EmailBrand>

const Email = t.brand( t.string, (s): s is Email => /^[^\s@]+@[^\s@]+.[^\s@]+$/.test(s), 'Email' )

interface NonEmptyStringBrand { readonly NonEmptyString: unique symbol } type NonEmptyString = t.Branded<string, NonEmptyStringBrand>

const NonEmptyString = t.brand( t.string, (s): s is NonEmptyString => s.trim().length > 0, 'NonEmptyString' )

interface PositiveIntBrand { readonly PositiveInt: unique symbol } type PositiveInt = t.Branded<number, PositiveIntBrand>

const PositiveInt = t.brand( t.number, (n): n is PositiveInt => Number.isInteger(n) && n > 0, 'PositiveInt' )

// Codec using branded types const ValidatedUserCodec = t.type({ name: NonEmptyString, email: Email, age: PositiveInt })

type ValidatedUser = t.TypeOf<typeof ValidatedUserCodec>

Integration with Zod

Zod with fp-ts Either

import { z } from 'zod' import * as E from 'fp-ts/Either' import * as NEA from 'fp-ts/NonEmptyArray' import { pipe } from 'fp-ts/function'

// Define Zod schema const UserSchema = z.object({ name: z.string().min(2, 'Name must be at least 2 characters'), email: z.string().email('Invalid email format'), age: z.number().int().min(0).max(150), role: z.enum(['admin', 'user', 'guest']) })

type ZodUser = z.infer<typeof UserSchema>

// Convert Zod errors to our format interface ZodValidationError { field: string message: string }

const fromZodError = (error: z.ZodError): NEA.NonEmptyArray<ZodValidationError> => { const errors = error.errors.map(err => ({ field: err.path.join('.') || 'root', message: err.message })) return errors as NEA.NonEmptyArray<ZodValidationError> }

// Parse with Either const parseUser = (input: unknown): E.Either<NEA.NonEmptyArray<ZodValidationError>, ZodUser> => { const result = UserSchema.safeParse(input) return result.success ? E.right(result.data) : E.left(fromZodError(result.error)) }

// Combine Zod with additional fp-ts validation const validateUser = (input: unknown): E.Either<NEA.NonEmptyArray<ZodValidationError>, ZodUser> => pipe( parseUser(input), E.chain(user => { // Additional business logic validation const errors: ZodValidationError[] = []

  if (user.role === 'admin' &#x26;&#x26; user.age &#x3C; 18) {
    errors.push({ field: 'role', message: 'Admins must be at least 18 years old' })
  }

  return errors.length > 0
    ? E.left(errors as NEA.NonEmptyArray&#x3C;ZodValidationError>)
    : E.right(user)
})

)

Zod Refinements with Error Accumulation

import { z } from 'zod' import * as E from 'fp-ts/Either' import * as NEA from 'fp-ts/NonEmptyArray' import { sequenceS } from 'fp-ts/Apply'

// Separate schemas for independent validation const nameSchema = z.string().min(2, 'Name must be at least 2 characters') const emailSchema = z.string().email('Invalid email format') const ageSchema = z.number().int().min(0, 'Age must be non-negative').max(150, 'Age must be at most 150')

interface ValidationError { field: string message: string }

type ValidationErrors = NEA.NonEmptyArray<ValidationError>

const parseWithField = <T>(schema: z.ZodType<T>, field: string) => (value: unknown): E.Either<ValidationErrors, T> => { const result = schema.safeParse(value) return result.success ? E.right(result.data) : E.left(NEA.of({ field, message: result.error.errors[0]?.message || 'Validation failed' })) }

const applicativeValidation = E.getApplicativeValidation(NEA.getSemigroup<ValidationError>())

// Validate with error accumulation const validateUserAccumulated = (input: { name: unknown; email: unknown; age: unknown }) => sequenceS(applicativeValidation)({ name: parseWithField(nameSchema, 'name')(input.name), email: parseWithField(emailSchema, 'email')(input.email), age: parseWithField(ageSchema, 'age')(input.age) })

// Usage - all errors collected validateUserAccumulated({ name: 'A', email: 'invalid', age: -5 }) // Result: Left([ // { field: 'name', message: 'Name must be at least 2 characters' }, // { field: 'email', message: 'Invalid email format' }, // { field: 'age', message: 'Age must be non-negative' } // ])

Best Practices

  1. Choose the Right Pattern

// Use fail-fast (chain) when: // - Validations depend on each other // - You want to short-circuit on first failure // - Performance is critical

// Use error accumulation (sequenceT/sequenceS) when: // - Validations are independent // - You want to show all errors to users // - Building form validation

  1. Structure Error Types

// Good: Structured errors with codes interface StructuredError { code: string // Machine-readable field: string // Which field failed message: string // Human-readable metadata?: unknown // Additional context }

// Bad: Just strings type BadError = string

  1. Separate Schema Validation from Business Rules

// Schema validation (structure/types) - use io-ts or Zod const UserSchema = z.object({ email: z.string().email(), age: z.number() })

// Business rules - use fp-ts validation const validateBusinessRules = (user: User) => sequenceS(applicativeValidation)({ emailDomain: validateCorporateEmail(user.email), ageRestriction: validateAgeForRole(user.age, user.role) })

// Combine both const validateUser = (input: unknown) => pipe( parseSchema(input), E.chain(validateBusinessRules) )

  1. Create Reusable Validator Combinators

// Combinator that runs multiple validators on same value const all = <E, A>(...validators: Array<(a: A) => E.Either<NEA.NonEmptyArray<E>, A>>) => (value: A): E.Either<NEA.NonEmptyArray<E>, A> => pipe( validators.map(v => v(value)), results => results.reduce( (acc, result) => pipe( sequenceS(E.getApplicativeValidation(NEA.getSemigroup<E>()))({ acc, result }), E.map(() => value) ), E.right(value) as E.Either<NEA.NonEmptyArray<E>, A> ) )

// Usage const validatePassword = all( minLength('password', 8), hasUppercase('password'), hasLowercase('password'), hasNumber('password') )

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

fp-ts-backend

No summary provided by upstream source.

Repository SourceNeeds Review
General

fp-immutable

No summary provided by upstream source.

Repository SourceNeeds Review
General

fp-refactor

No summary provided by upstream source.

Repository SourceNeeds Review
General

practical error handling with fp-ts

No summary provided by upstream source.

Repository SourceNeeds Review