JavaScript and TypeScript represent the absence of data in several distinct ways. When working with objects, missing fields typically fall into one of three specific categories:
- Key omission: The key does not exist on the object at all.
- Present but
undefined: The key exists, but its value is explicitlyundefined. - Present but
null: The key exists, but its value is explicitlynull.
Effect Schema provides specific APIs for each of these cases, allowing you to be precise about which forms of absence your application should accept.
Making Fields Optional
The simplest way to make a field optional is optionalKey. It creates an exact optional property, meaning the key can be omitted from the object entirely, but if it is present, it must match the schema’s type.
import { Schema } from "effect";
const UserSchema = Schema.Struct({ name: Schema.String, age: Schema.optionalKey(Schema.Number),});
// Type: { readonly name: string; readonly age?: number }type User = typeof UserSchema.Type;
const decode = Schema.decodeUnknownSync(UserSchema);
// age is omitted — validconsole.log(decode({ name: "John" }));
// age is present — validconsole.log(decode({ name: "John", age: 24 }));
// age is present but wrong type — errortry { decode({ name: "John", age: "twenty four" });} catch (error) { if (error instanceof Error) { console.log("Error:", error.message); }}
// age is present but wrong type — errortry { decode({ name: "John", age: undefined });} catch (error) { if (error instanceof Error) { console.log("Error:", error.message); }}Output:
{ name: 'John' }{ name: 'John', age: 24 }Error: Expected number, got "twenty four" at ["age"]Error: Expected number, got undefined at ["age"]The age key can be absent, but if it’s there, it must be a number. Any other data type will result in a decoding failure.
Making Fields Optional with Undefined
Oftentimes you want a field that can be omitted or explicitly set to undefined. The optional function handles this case.
optional is equivalent to optionalKey(UndefinedOr(schema)). It creates a field that accepts three states: the key is absent, the key is present with undefined, or the key is present with a valid value.
import { Schema } from "effect";
const UserSchema = Schema.Struct({ name: Schema.String, age: Schema.optional(Schema.Number),});
// Type: { readonly name: string; readonly age?: number | undefined }type User = typeof UserSchema.Type;
const decode = Schema.decodeUnknownSync(UserSchema);
// age is omitted — validconsole.log(decode({ name: "Steve" }));
// age is explicitly undefined — validconsole.log(decode({ name: "Steve", age: undefined }));
// age is present — validconsole.log(decode({ name: "Steve", age: 30 }));Output:
{ name: 'Steve' }{ name: 'Steve', age: undefined }{ name: 'Steve', age: 30 }Mapping Optional Fields to Option
In the previous sections, we learnt how to represent optional fields as missing keys or undefined values. While Effect Schema lets us distinguish between missing keys and undefined values precisely, they are not always the most convenient shapes to work with in application code. In many cases, it is easier to convert all of these cases into a single representation: Option. Option is an Effect data type with only two possibilities: Some(value), which means a value is present, and None, which means it is absent. The key benefit of Option is that absence becomes explicit. Every consumer of the value must handle the None case.
Effect Schema provides two functions that transform optional fields into Option values during decoding.
OptionFromOptionalKey
OptionFromOptionalKey decodes a field that may be absent (an exact optional key) into an Option:
- If the key is missing, the decoded value is
Option.none(). - If the key is present, the decoded value is
Option.some(value).
import { Option, Schema } from "effect";
const UserSchema = Schema.Struct({ name: Schema.String, nickname: Schema.OptionFromOptionalKey(Schema.String),});
// Type: { readonly name: string; readonly nickname: Option.Option<string> }type User = typeof UserSchema.Type;
const decode = Schema.decodeUnknownSync(UserSchema);
// Key is absent — Noneconst aron = decode({ name: "Aron" });console.log(aron.nickname); // { _id: 'Option', _tag: 'None' }console.log(Option.isNone(aron.nickname)); // true
// Key is present — Someconst bob = decode({ name: "Bob", nickname: "Bobby" });console.log(bob.nickname); // { _id: 'Option', _tag: 'Some', value: 'Bobby' }console.log(Option.getOrElse(bob.nickname, () => "no nickname")); // BobbyOptionFromOptional
OptionFromOptional is the broader variant. It decodes a field that may be absent or explicitly undefined into an Option:
- If the key is missing, the decoded value is
Option.none(). - If the value is undefined, the decoded value is
Option.none(). - Otherwise, the decoded value is
Option.some(value).
import { Schema } from "effect";
const UserSchema = Schema.Struct({ name: Schema.String, nickname: Schema.OptionFromOptional(Schema.String),});
const decode = Schema.decodeUnknownSync(UserSchema);
// Key is absent - Noneconsole.log(decode({ name: "Anne" }).nickname);
// Value is undefined - Noneconsole.log(decode({ name: "Bob", nickname: undefined }).nickname);
// Value is present - Someconsole.log(decode({ name: "Charlie", nickname: "Chuck" }).nickname);Output:
{ _id: 'Option', _tag: 'None' }{ _id: 'Option', _tag: 'None' }{ _id: 'Option', _tag: 'Some', value: 'Chuck' }Nullable and Nullish Types
APIs commonly use null to represent empty values, and some systems use both null and undefined. Effect Schema provides three helpers for these cases.
NullOr
NullOr creates a schema that accepts either the wrapped type or null:
import { Schema } from "effect";
const UserSchema = Schema.Struct({ name: Schema.String, bio: Schema.NullOr(Schema.String),});
// Type: { readonly name: string; readonly bio: string | null }type User = typeof UserSchema.Type;
const decode = Schema.decodeUnknownSync(UserSchema);
console.log(decode({ name: "Hemanta", bio: "Hello!" }));console.log(decode({ name: "Hemanta", bio: null }));Output:
{ name: 'Hemanta', bio: 'Hello!' }{ name: 'Hemanta', bio: null }The bio field is still required — you must provide the key. But the value can either be a string or null.
UndefinedOr
UndefinedOr creates a schema that either accepts the wrapped type or undefined.
import { Schema } from "effect";
const UserSchema = Schema.Struct({ name: Schema.String, bio: Schema.UndefinedOr(Schema.String),});
// Type: { readonly name: string; readonly bio: string | undefined }type User = typeof UserSchema.Type;
const decode = Schema.decodeUnknownSync(UserSchema);
console.log(decode({ name: "Hemanta", bio: "Hello!" }));console.log(decode({ name: "Hemanta", bio: undefined }));Output:
{ name: 'Hemanta', bio: 'Hello!' }{ name: 'Hemanta', bio: undefined }Note that UndefinedOr on its own does not make the key optional. The bio key must still be present in the input. It just allows undefined as a valid value. If you want the key to be omissible as well, use optional, which combines optionalKey with UndefinedOr.
NullishOr
NullishOr accepts the wrapped type, null or undefined.
import { Schema } from "effect";
const UserSchema = Schema.Struct({ name: Schema.String, bio: Schema.NullishOr(Schema.String),});
// Type: { readonly name: string; readonly bio: string | null | undefined }type User = typeof UserSchema.Type;
const decode = Schema.decodeUnknownSync(UserSchema);
console.log(decode({ name: "Hemanta", bio: "Hello!" }));console.log(decode({ name: "Hemanta", bio: null }));console.log(decode({ name: "Hemanta", bio: undefined }));Output:
{ name: 'Hemanta', bio: 'Hello!' }{ name: 'Hemanta', bio: null }{ name: 'Hemanta', bio: undefined }This is common when consuming APIs that don’t distinguish between null and undefined.
Mapping Nullable Fields to Option
The previous section mapped optional fields (missing keys and undefined values) into Option. The same idea applies to nullable fields. Instead of working with string | null in your application code, you can decode the value into an Option, collapsing null into None and keeping valid values as Some.
OptionFromNullOr
OptionFromNullOr decodes a required field whose value might be null:
nullbecomesOption.none().- Any other valid value becomes
Option.some(value).
import { Option, Schema } from "effect";
const UserSchema = Schema.Struct({ name: Schema.String, bio: Schema.OptionFromNullOr(Schema.String),});
// Type: { readonly name: string; readonly bio: Option.Option<string> }type User = typeof UserSchema.Type;
const decode = Schema.decodeUnknownSync(UserSchema);
// null - Noneconst anne = decode({ name: "Anne", bio: null });console.log(Option.isNone(anne.bio)); // true
// string - Someconst bob = decode({ name: "Bob", bio: "Hello!" });console.log(Option.getOrElse(bob.bio, () => "no bio")); // Hello!Quick Reference
| Function | What it does |
|---|---|
optionalKey | Key can be omitted, but if present, must match the type |
optional | Key can be omitted or set to undefined |
NullOr | Value can be the type or null |
UndefinedOr | Value can be the type or undefined |
NullishOr | Value can be the type, null, or undefined |
OptionFromOptionalKey | Missing key becomes None, present value becomes Some |
OptionFromOptional | Missing key or undefined becomes None, present value becomes Some |
OptionFromNullOr | null becomes None, present value becomes Some |