TypeScript: Turn a Container<Maybe<unknown>> into Maybe<Container<unknown>>

Viewed 906

Using Typescipt 4.x.x

I wrote some code to implement the Maybe/Option type commonly used in other languages Elm/Rust/Haskell.

I wanted to write a generic function that could take a mapped type

type MyMappedType<T> = {
    value: T
    name: string
}

which has a Maybe<unknown> as one of its type parameters eg. MyMappedType<Maybe<unknown>> and return a Maybe<MappedType<unknown>>.

However I could not find a nice way to describe this, below is my attempt and the extract function is the one of interest. It currently just iterates over all the fields, but ideally I could use some typescript smarts to only inspect the field/fields that contain a Maybe. Can this be done in a generic way?

example can be run in the TypeScript Playground

export type Just<T> = [NonNullable<T>]

export const nothing = []

export type Nothing = typeof nothing

export type Maybe<T> = Just<T> | Nothing

export function just<T>(t: NonNullable<T>): Maybe<T> {
    return [t]
}

export function map<T, R>(t: Maybe<T>, f: (t: T, index?: number) => R): Maybe<R> {
    return t.map(f) as Maybe<R>
}

export function isJust<T>(t: Maybe<T>): t is Just<T> {
    // Type guards are runtime checked, so TS tuples are actually Arrays
    return Array.isArray(t) && t.length == 1 ? true : false
}

export function isNothing<T>(t: Maybe<T>): t is Nothing {
    return Array.isArray(t) && t.length == 0? true : false
}


export function unwrap<T>(t: Just<T>) {
    return t[0] as T
}

export function filter<T>(a: Maybe<T>[]): T[] {
    return a.filter(isJust).map(unwrap)
}

export function defaultMapUnwrap<T, D>(t: Maybe<T>, f: (t: T, index?: number) => D, d: D): D {
    return isJust(t) ? f(unwrap(t)) : d
}

type Container<Inner> = {} // <- this is bad
// How do I make this function better?
export function extract<Inner>(t: Container<Maybe<Inner>>): Maybe<Container<Inner>>{
    let result = {}
    for (let [key,value] of Object.entries(t)){
        if (isNothing(value as Maybe<any>)){
            return nothing
        } 
            Object.assign(result, {[key]: defaultMapUnwrap(value as Maybe<any>, inner=> inner, value)})
    }
    return just(result)
}
2 Answers

It currently just iterates over all the fields, but ideally I could use some typescript smarts to only inspect the field/fields that contain a Maybe. Can this be done in a generic way?

Your generic Container<T> type should know which fields can be T. In your example, Container<T> is just an empty object shape, so what's the point of having a type parameter, or even the type at all?

Additionally, I would recommend using interfaces rather than types for the definition of object shapes. That will allow other shapes to extend from it, ensuring the properties of the extension have the properties of the super. For example:

interface Container<T> {
  value: T;
}
interface MyContainer<T> extends Container<T> {
  name: 'bob';
}
const myContainer: MyContainer<'builds'> = {
  name: 'bob',
  value: 'builds',
};

Now you know which fields may be Maybe<T> for any generic Container<Maybe<T>>. If you want to also know which fields can be Maybe<T> for a more specific Container extension, you will need separate handling for that specific type. With type-safety in mind: in your example, you just iterate over all of the fields and compare it with nothing, but how would the interpreter know that a value is meant to be interpreted as nothing = [] and not an empty array of data?

If you want to iterate over the keys of a Container to find Maybe<T> values, it may be helpful to create a class Maybe<T>. That way you can check that a value is an instanceof Maybe.

Unfortunately, as of TypeScript 4.3.2, there is no sense of a generic parameterized type parameter, so you can't do something like this to get back the same parameterized type that you passed in (along with some code that you may find useful):

// NOT A VALID SIGNATURE!!!
function extract<T, A extends Container>(t: A<Maybe<T>>): Maybe<A<T>> {
  return t.value.length === 0
    ? nothing
    : [
        {
          ...t,
          value: t.value[0],
        },
      ];
}
Aside

I think your definitions of Just<T> and Nothing are a bit odd. You define them as an single-value array tuple and an empty array tuple, but remove null and undefined types from your toolbelt with NonNullable.

I would instead define them like this to open up more possibilities for your code:

type Just<T> = [T];
type Nothing = [];
type Maybe<T> = Just<T> | Nothing;

const x: Maybe<string | undefined> & Nothing = [];
const y: Maybe<string | undefined> & Just<string | undefined> = [undefined];
const z: Maybe<string | undefined> & Just<string | undefined> = ['something else'];
console.log(x.length); // 0
console.log(y.length); // 1
console.log(z.length); // 1

There's a few things in your code which are less than ideal.

If you put it all into one step, your Maybe type is this:

export type Maybe<T> = [NonNullable<T>] | []

Your isJust and isNothing functions do not need to check Array.isArray(t) because t which is Maybe<T> is always an array. You can also remove the ternary and just return the boolean from the comparison.

export function isJust<T>(t: Maybe<T>): t is Just<T> {
    return t.length === 1;
}

export function isNothing<T>(t: Maybe<T>): t is Nothing {
    return t.length === 0;
}

unwrap does not need to assert as T because t[0] is always T.


export function unwrap<T>(t: Just<T>): T {
    return t[0];
}

You generally don't want to broaden a return type. A Just<T> is also a Maybe<T> (but not vice-versa). Your just function should return Just<T> instead of Maybe<T> so that you have the maximum amount of information.

export function just<T>(t: NonNullable<T>): Just<T> {
    return [t]
}

So now we're getting to the extract and the Container, which seems unfinished because what even is a Container? It's just an empty object with no relation to <Inner>. Your MyContainer type makes sense. Is that what it's supposed to be?

You have an object with a property value that is a Maybe<string> and a property name that is string. If the value is a Nothing then you return a Nothing. Otherwise you return basically the same object but with the Maybe<string> property unwrapped to a string.

I think that you are trying to generalize this but I'm not sure the "rules". Is the Maybe property always value? Could multiple properties be Maybe? It's tricky that some properties are Maybe and others are not. But we can work with this.

Let's use a mapped type to map any Maybe property to its unwrapped version. The T here is the type for the original container object. If a property is a Maybe<U> we replace it with U, otherwise it stays the same type.

type UnwrappedContainer<T> = {
    [K in keyof T]: T[K] extends Maybe<infer U> ? U : T[K]
}

If you want better automatic type inference, you might consider unwrapping all arrays. Typescript will not see ['hello'] as Maybe<string> unless explicitly told to do so. That's because it infers the type as an array string[] rather than a tuple [string].

type UnwrappedContainer<T> = {
    [K in keyof T]: T[K] extends Array<infer U> ? U : T[K]
}

The signature that you want for your function is this, where C is a generic type parameter describing the input container.

export function extract<C>(t: C): Maybe<UnwrappedContainer<C>>{

We've come to the implementation. I'm now seeing why you had the Array.isArray(t) check in isNothing before. It's because you were calling the function with variables that are not Maybe<T> and avoiding Typescript errors by asserting that they are in fact Maybe<T>. You should not assert things that you know to be false. The name property 'james' is one of the object values that you are checking and we know it's not a Maybe.

You can do the Array.isArray check in the function, but with the way that you are calling it now you would want isJust and isNothing to check unknown values and not just values which are already known to be Maybe. The T doesn't matter in the case of isNothing so that one is simple.

export function isNothing(t: unknown): t is Nothing {
    return Array.isArray(t) && t.length === 0;
}

With isJust we have to infer the T from an unknown. I am making the generic here refer to the whole input. If provided with [string] the T would be [string] rather than string. This has gotten stupidly complicated but I think it's accurate.

export function isJust<T>(t: T): t is T & Just<T extends Array<infer U> ? U : never> {
    return Array.isArray(t) && t.length === 1;
}

It's a lot easier if you do the Array.isArray check outside of this function and you're just validating the length.

export function isJust<T>(t: T[]): t is Just<T> {
    return t.length === 1;
}

I'm not seeing the need to use defaultMapUnwrap in extract because you aren't mapping anything (inner => inner) and you've already validated that value isn't empty, so there's no need for a default.

export function extract<C>(t: C): Maybe<UnwrappedContainer<C>>{
    const result = {} as UnwrappedContainer<C>;
    for (const [key, value] of Object.entries(t)) {
        if ( Array.isArray(value) ) {
            // If there are any Nothing properties then the whole function returns []
            if ( isNothing(value) ) {
                return nothing;
            }
            // Unwrap non-empty arrays
            result[key as keyof C] = value[0];
        }
        // properties which are not arrays just get returned as-is
        else {
            result[key as keyof C] = value;
        }
    }
    return just(result);
}

My implementation isn't actually checking anywhere that value.length === 1, just that it's greater than 0. We map an object such that if it has any array properties then we replace the array with its first element. If any of those arrays are empty then the entire function returns [].

I guess the only thing I'm not covering is the issue of NonNullable and what happens if your array is [null]. You should probably check that, but at this point I've written enough so I'll leave that to you!

TypeScript Playground Link

Related