Skip to main content

Partial optics

By default an optic will always be able to retrieve the value it's focused on, we say that's it's total.

But an optic can also be partial, meaning it focuses on a value that might not exist.
When you read a value focused by a partial optic you might get undefined if the value is not present.

When you derive new optics from an optional or nullable property, the resulting optics end up partial.
As an example let's create users with an optional contact property:

ts
const initialUsers: { name: string; contact?: { phone: string } }[] = [
{ name: "Vincent", contact: { phone: "999-999-999" } },
{ name: "Gabin" },
];
 
const userOptics = createState(initialUsers);
ts
const initialUsers: { name: string; contact?: { phone: string } }[] = [
{ name: "Vincent", contact: { phone: "999-999-999" } },
{ name: "Gabin" },
];
 
const userOptics = createState(initialUsers);

Here some users might not have any contact information, so the contact field is optional.

Every optic we derive from contact ends up partial:

ts
const firstUserPhoneOptic = userOptics[0].contact.phone;
const firstUserPhoneOptic: Optic<string, partial>
 
const secondUserPhoneOptic = userOptics[1].contact.phone;
const secondUserPhoneOptic: Optic<string, partial>
ts
const firstUserPhoneOptic = userOptics[0].contact.phone;
const firstUserPhoneOptic: Optic<string, partial>
 
const secondUserPhoneOptic = userOptics[1].contact.phone;
const secondUserPhoneOptic: Optic<string, partial>

A partial optic returns undefined if one of the value in the path is not present (in our case contact), that's why the return type of get is T | undefined:

ts
const vincentsPhone = firstUserPhoneOptic.get();
const vincentsPhone: string | undefined
 
const gabinsPhone = secondUserPhoneOptic.get();
const gabinsPhone: string | undefined
 
// vincentsPhone = "999-999-999"
// gabinsPhone = undefined
ts
const vincentsPhone = firstUserPhoneOptic.get();
const vincentsPhone: string | undefined
 
const gabinsPhone = secondUserPhoneOptic.get();
const gabinsPhone: string | undefined
 
// vincentsPhone = "999-999-999"
// gabinsPhone = undefined

When trying to update a value with a partial that is not focused on a value, the update is ignored:

ts
secondUserPhoneOptic.set("888-888-888");
 
secondUserPhoneOptic.get(); // undefined
ts
secondUserPhoneOptic.set("888-888-888");
 
secondUserPhoneOptic.get(); // undefined
tip

On a plain JS object if you have to use the optional chaining operator ?. on the path to a property, then it means using the same path on the optic will get you a partial optic (no need to use ?. on optics though).

ts
initialUsers[0].contact?.phone;
(property) phone: string | undefined
 
userOptics[0].contact.phone;
(property) phone: Optic<string, partial>
ts
initialUsers[0].contact?.phone;
(property) phone: string | undefined
 
userOptics[0].contact.phone;
(property) phone: Optic<string, partial>

Another way you can end up with partial optics is with some combinators.
For exemple with an optic focused on an array, the find combinator returns a partial optic because no element of the array might satisfy the predicate.

ts
import { find } from "@optics/react/combinators";
 
const numbersOptic = createState([1, 2, 3, 4]);
 
const greaterThanThreeOptic = numbersOptic.derive(find((n) => n > 3));
const greaterThanThreeOptic: Optic<number, partial>
 
greaterThanThreeOptic.get(); // 4
 
numbersOptic[3].set(0);
greaterThanThreeOptic.get(); // undefined
ts
import { find } from "@optics/react/combinators";
 
const numbersOptic = createState([1, 2, 3, 4]);
 
const greaterThanThreeOptic = numbersOptic.derive(find((n) => n > 3));
const greaterThanThreeOptic: Optic<number, partial>
 
greaterThanThreeOptic.get(); // 4
 
numbersOptic[3].set(0);
greaterThanThreeOptic.get(); // undefined

Narrowing

We can assign a total optic to a partial one (widening the type):

ts
const numberOptic = createState(42);
 
const numberPartialOptic: Optic<number, partial> = numberOptic; // ✅ allowed
ts
const numberOptic = createState(42);
 
const numberPartialOptic: Optic<number, partial> = numberOptic; // ✅ allowed

However the reverse is not true, you can't narrow from partial to a total as it isn't type-safe:

ts
const evenNumberOptic = createState([1, 2, 3]).derive(find((n) => n % 2 === 0));
const evenNumberOptic: Optic<number, partial>
 
const numberTotalOptic: Optic<number> = evenNumberOptic;
Type 'Optic<number, partial>' is not assignable to type 'Optic<number>'. The types returned by 'get()' are incompatible between these types. Type 'number | undefined' is not assignable to type 'number'. Type 'undefined' is not assignable to type 'number'.2322Type 'Optic<number, partial>' is not assignable to type 'Optic<number>'. The types returned by 'get()' are incompatible between these types. Type 'number | undefined' is not assignable to type 'number'. Type 'undefined' is not assignable to type 'number'.
ts
const evenNumberOptic = createState([1, 2, 3]).derive(find((n) => n % 2 === 0));
const evenNumberOptic: Optic<number, partial>
 
const numberTotalOptic: Optic<number> = evenNumberOptic;
Type 'Optic<number, partial>' is not assignable to type 'Optic<number>'. The types returned by 'get()' are incompatible between these types. Type 'number | undefined' is not assignable to type 'number'. Type 'undefined' is not assignable to type 'number'.2322Type 'Optic<number, partial>' is not assignable to type 'Optic<number>'. The types returned by 'get()' are incompatible between these types. Type 'number | undefined' is not assignable to type 'number'. Type 'undefined' is not assignable to type 'number'.

You can cast a partial optic to a total one with the whenFocused function returned by useOptic:

tsx
const [, { whenFocused }] = useOptic(evenNumberOptic);
const evenNumberOptic: Optic<number, partial>
whenFocused((evenNumberTotalOptic) => {
(parameter) evenNumberTotalOptic: Optic<number, Omit<partial, "partial">>
});
tsx
const [, { whenFocused }] = useOptic(evenNumberOptic);
const evenNumberOptic: Optic<number, partial>
whenFocused((evenNumberTotalOptic) => {
(parameter) evenNumberTotalOptic: Optic<number, Omit<partial, "partial">>
});

The callback will only run when the optic is focused on a value, so that it's safe to cast the optic to total inside it.

In most cases your components should only expect total optics in their props. Use whenFocused to narrow the eventual partial optics before passing them to components.