#Can I make a non-function type that can be restricted by `typeof`

86 messages · Page 1 of 1 (latest)

loud lynx
#

I'm trying to differentiate an arg passed to a function with multiple overloads by whether the arg is an function or not and process it differently. But I cannot figure out a way to define a non-function type that can be ruled out by typeof value === 'function. My current attempt is below

naive craterBOT
#
diff3usion#0

Preview:```ts
type NonFunction<T> = T extends Function ? never : T

function foo<T>(value: NonFunction<T>): void
function foo<T>(value: () => string): void
function foo<T>(
value: NonFunction<T> | (() => string)
) {
if (typeof value === "function") {
console.log("function", value())
...```

eager plover
#

yeah you can't really do negated types like this. can it be literally anything besides a function?

loud lynx
#

Yes the use case is like I want to define a function that takes either a T or a () => T as 2 overloaded definitions, kind of like react's useState, and I want to differentiate args in runtime using typeof while keeping typescript happy.

Specifically I am working on this helper for now, and I want to reduce the number of as es...

type NonFunction<T> = T extends Function ? never : T
type ForwardNullity<T, R> = R | (null extends T ? null : never) | (undefined extends T ? undefined : never)

/**
 * `maybe` returns the result if the value is not null or undefined, otherwise forwards the nullity.
 */
export function maybe<V, R>(value: V, mapper: (value: NonNullable<V>) => R): ForwardNullity<V, R>
export function maybe<V, R>(value: V, result: NonFunction<R>): ForwardNullity<V, R>
export function maybe<V, R>(
  ...args: [value: V, mapper: (value: NonNullable<V>) => R] | [value: V, result: NonFunction<R>]
): ForwardNullity<V, R> {
  const [value, mapperOrResult] = args
  if (value === null) return null as ForwardNullity<V, R>
  if (value === undefined) return undefined as ForwardNullity<V, R>
  if (typeof mapperOrResult === 'function') {
    return (mapperOrResult as (value: NonNullable<V>) => R)(value)
  } else {
    return mapperOrResult
  }
}
eager plover
#

why do you want overload signatures? nothing you've shown so far requires them; the implementation signature should be fine as the one and only signature

#

this is probably sufficient:

naive craterBOT
#
mkantor#0

Preview:ts ... export function maybe<V, R>( value: V, mapperOrResult: (value: NonNullable<V>) => R | R ): ForwardNullity<V, R> { if (value === nul ...

loud lynx
#

Yes I'm just using overload signatures for readability, and this problem is not related to overloading anyway

eager plover
#

actually i'd probably write mapperOrResult: R | ((value: NonNullable<V>) => R) to avoid confusion about operator precedence

eager plover
#

(also personally i find them less readable, but YMMV)

loud lynx
#

Okay thanks for the advice and I may reconsider my view about overload. But is there really no better way to leverage typeof restricting T | (() => T)?

eager plover
#

one problem with that whole idea is that functions are objects in javascript

#

it might seem like object | (() => Foo) should work, but object can still be a function

#

think about like the global Date object for example

#

should it be possible to have a mapper that returns a function in your code?

loud lynx
#

I was prepared to sacrifice a little bit of expressiveness by banning the mapper from returning a function and NonFunction<R> did behave that way, so for my current version the mapper cannot return a function.

Oops I tried again the current version of maybe does accept a mapper returning a function. It can be (value: NonNullable<V>) => NonFunction<R> to be more restrictive about that

eager plover
#

this isn't the question you asked, but IMO this function is doing too many things. if you can restrict its scope then everything will be much simpler

#

if you're open to this kind of feedback, can you show me some examples of how you intend to use this function? i could give more specific suggestions with that in mind

loud lynx
# eager plover if you're open to this kind of feedback, can you show me some examples of how yo...

Sure. Any suggestions are welcomed. I'm just trying to solve a common problem in my codebase that patterns like

someValue !== undefined ? getNewValue(someValue) : undefined
someValue !== null ? newValue : null

are verbose and error-prone. Using maybe they can be as simple as

maybe(someValue, getNewValue)
maybe(someValue, newValue)

and with ForwardNullity it preserves the null or undefined

Of course I can use separate function names for these 2 use cases, but I'd like to keep the concepts of my basic util functions as simple as possible, so I prefer using the same util function name.

#

And I think there're many other use cases when it would be helpful if I can have a function's parameter either a generic value T or a function returning it () => T, for all kinds of reasons. And the ability to use typeof differentiate them at runtime would be useful.

eager plover
#

let's just focus on the "do something conditionally depending on whether a value is nullish" case first (but we can come back to that second thing)

#

i don't think i understand the motivation to make that generic (e.g. why ForwardNullity is needed). that would only be useful if the caller already knows that the value cannot be null/undefined, and in that case why would they call maybe at all?

#

for example, perhaps something like this would be good enough:

naive craterBOT
#
mkantor#0

Preview:ts function maybe<T extends {}, R>( value: T | null | undefined, f: (value: T) => R ): R | null | undefined { return value === null || value === undefined ? value : f(value) }

loud lynx
eager plover
#

ah, so it's more about distinguishing T | null from T | undefined than it is T from T | null | undefined?

loud lynx
#

Yes I think so

eager plover
#

sorry stepped away to make some coffee

#

can you tell me more about why you have to combine the handling for null and undefined in the first place? most codebases i see pick one or the other to represent "nothing"

loud lynx
#

Well on behalf of this maybe utility I think it's just trying to leave this to the user. It should does the job no matter if the user cares about the difference between null and undefined or not.

#

And I think it's quite beside the point 😂

eager plover
#

well the function is going out of its way to have an opinion as to what values to handle specially. it could also decide to handle false, 0, '' or whatever else, but doesn't. it could also handle just undefined or null, but doesn't. it's all somewhat arbitrary, so i figured there might be a specific reason for this API

loud lynx
#

actually I'm using another utility for truthiness

export function given<V, R>(value: V, getter: () => R): R | undefined
export function given<V, R>(value: V, result: NonFunction<R>): R | undefined
export function given<V, R>(...args: [value: V, getter: () => R] | [value: V, result: NonFunction<R>]): R | undefined {
  const [value, getterOrResult] = args
  if (!value) return undefined
  if (typeof getterOrResult === 'function') {
    return (getterOrResult as () => R)()
  } else {
    return getterOrResult
  }
}
eager plover
#

is this part of a library meant for consumption by third parties? or just something you're using in your own codebase(s)?

loud lynx
#

It's just for my own codebase

eager plover
#

yeah, then i think being opinionated is good. e.g. i typically choose undefined to model "nothing", so if there's a null floating around, it either has a specific meaning (and for example i would want mapper(null) to be called) or it's a bug (in which case i want a type error so i can figure out where the null is coming from)

#

but obviously this is all somewhat subjective

loud lynx
#

Yes I think everyone may have their own opinion about how undefined and null should be used, and that's something to be considered when coordinating multiple third party libraries. So the best thing I can do is to always keep the typings specific about nullity

eager plover
#

how burdensome would it be to have separate maybeNull and maybeUndefined functions? then for example if you call a third-party function that may return either null and undefined to represent different things, you're forced to confront that rather than potentially mishandling one or the other

#

(99% of libraries will always either use one or the other though)

loud lynx
#

I was indeed handling null and undefined separately but later I found it tedious to choose one each time, and then I figure out ForwardNullity should solve this problem because it just preserves either null or undefined that was still actively generated somewhere else, so it just passes something that should have already been specific. Also I would like to keep the name of this utility as short as possible, because it's basically doing something that should ideally be done by some operator like ??, so it should have minimal impact on the logics it wraps. I know lots of people may be using someValue && newValue for cases like someValue !== null ? newValue : null, which does not differentiate nullity and truthiness well

eager plover
#

yeah i've also wanted the equivalent && version of ?? before too

#

anyway, if you're set on these semantics, perhaps something like this:

function maybe<T, R>(
  value: T,
  f: (value: NonNullable<T>) => R
): R | Exclude<T, {}> {
  return (value === null || value === undefined)
    ? value as Exclude<T, {}>
    : f(value)
}
#

i don't think it's possible to write this without a type assertion or overloads, neither of which are typesafe

#

(i know that lacks the "R can be a bare value" thing. just was focused on the nullishness part here)

loud lynx
#

Actually I would like to hear your view about why overloads are not safe, I have always been thinking them as readability syntax sugars for complicated union parameters

eager plover
#

here's an example:

naive craterBOT
#
function f(x: string): string
function f(x: number): number
function f(x: string | number): string | number {
  return 42
}

f('oops').toUpperCase() // runtime exception, yet no type error
eager plover
#

the function body is only checked against the implementation signature, but the overload signatures visible to callers may be more constrained

#

that will allow writing some functions like this with conditional return types in a way that can actually be checked by the compiler

loud lynx
#

I see, yeah it's indeed unsafe

loud lynx
eager plover
#

{} means "any value that may have properties", i.e. "anything other than null or undefined"

#

so that picks out the null and/or undefined from T

#

it may be enlightening to learn that unknown is defined internally as {} | null | undefined

loud lynx
eager plover
#

yeah, the notation is a bit odd when you think deeply about it. a string can also be assigned to { length: number }, which some folks might find odd

#

i kinda wish TS had chosen different delimiters so as not to suggest object-ness

loud lynx
#

Yes that would make it much less confusing

#

So my updated version of maybe is here

// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
type NonFunction<T> = T extends Function ? never : T
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
type ForwardNullity<T, R> = Exclude<T, {}> | R

/**
 * `maybe` returns the result if the value is not null or undefined, otherwise forwards the nullity.
 */
export function maybe<V, R>(
  value: V,
  mapperOrResult: ((value: NonNullable<V>) => R) | NonFunction<R>,
): ForwardNullity<V, R> {
  if (value === null || value === undefined) return value as ForwardNullity<V, R>
  if (typeof mapperOrResult === 'function') {
    return (mapperOrResult as (value: NonNullable<V>) => R)(value)
  } else {
    return mapperOrResult
  }
}

I really learned something about overloading and {}. @eager plover Thanks!
I'll leave this question open for some time in case anyone may have any clever ideas on optimizing the mapperOrResult as (value: NonNullable<V>) => R part but not with much hope🥲

eager plover
#

personally i would not even have that NonFunction type. it doesn't really work the way you want and will just lead to confusion later

#

for example:

naive craterBOT
#
type NonFunction<T> = T extends Function ? never : T

function f<T>(x: NonFunction<T>) {}

const definitelyAFunction: unknown = () => {}
f(definitelyAFunction) // no error
proper parrot
#

I use a curried version of this pretty frequently, and semi-regularly think about trying to write it to handle either null or undefined, and then decide it's not worth the effort 😆

type MaybeType = <V, R>(fn: (value: V) => R) => (value: V | undefined) => R | undefined;
const maybe: MaybeType = (fn) => (value) => (value === undefined ? value : fn(value));

// usage
declare const s: string | undefined;
const n = maybe(parseInt)(s);
loud lynx
ember verge
#

If you want any function, you'd use (...args: never) => unknown instead. Function is not a good type to use (even ESLint warns you about it 😄 )

eager plover
#

@loud lynx what would you expect to happen if i call maybe('foo', Date)?

loud lynx
loud lynx
loud lynx
ember verge
#

I'm only loosely following the conversation, but imo most of the issue comes from the design decision that you want it to support both mapper or result.

#

It's a lot easier if you have two versions dedicated for each.

loud lynx
#

Yes for this specific function I guess so. Still I'm curious about the possibility to separately handle function / non-function for a generic parameter

ember verge
#

I don't think it's possible without negated type.

#

Fundamentally the issue is that for T | (() => T), there's no way for you to tell which variant it is. typeof t === 'function' does not work because T could itself be a function.

eager plover
#

not to mention the fact that there's fundamentally no way to do runtime narrowing of the parameter count/types. even (() => unknown) | ((x: unknown) => unknown) is impossible to narrow robustly

#

discriminated unions are usually the way to go for stuff like this (though that would undermine the goal in this case of reducing the amount of ceremony required at usage sites)

loud lynx
eager plover
#

and TS doesn't narrow based on a .length check either, so you'd have to assert

loud lynx
#

yeah so just for a tiny fraction of use cases, probably better never rely on that

#

I think we can call it a day for this question and mark it as resolved. Thanks to you all for the discussion!

#

!resolved