Skip to content

Commit

Permalink
Merge pull request #196 from wagenet/better-types
Browse files Browse the repository at this point in the history
Improve types for `and` and `or`
  • Loading branch information
SergeAstapov authored Sep 7, 2023
2 parents b4e6330 + 5ae7d3a commit dac1476
Show file tree
Hide file tree
Showing 13 changed files with 267 additions and 27 deletions.
2 changes: 2 additions & 0 deletions packages/ember-truth-helpers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"lint:hbs:fix": "ember-template-lint . --fix --no-error-on-unmatched-pattern",
"lint:js:fix": "eslint . --fix",
"lint:types": "glint",
"lint:types-tests": "glint --project type-tests",
"start": "concurrently 'npm:start:*'",
"start:js": "rollup --config --watch --no-watch.clearScreen",
"start:types": "glint --declaration --watch",
Expand Down Expand Up @@ -57,6 +58,7 @@
"eslint-plugin-ember": "^11.10.0",
"eslint-plugin-n": "^16.0.1",
"eslint-plugin-prettier": "^4.0.0",
"expect-type": "^0.16.0",
"prettier": "^2.8.8",
"rollup": "^3.26.2",
"typescript": "^5.0.4",
Expand Down
24 changes: 19 additions & 5 deletions packages/ember-truth-helpers/src/helpers/and.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
import Helper from '@ember/component/helper';
import truthConvert from '../utils/truth-convert.ts';
import truthConvert, {
MaybeTruthy,
TruthConvert,
} from '../utils/truth-convert.ts';

interface AndSignature<T extends unknown[]> {
type FirstFalsy<T> = T extends [infer Item]
? Item
: T extends [infer Head, ...infer Tail]
? TruthConvert<Head> extends false
? Head
: TruthConvert<Head> extends true
? FirstFalsy<Tail>
: Head | FirstFalsy<Tail>
: undefined;

interface AndSignature<T extends MaybeTruthy[]> {
Args: {
Positional: T;
};
Return: T[number];
Return: FirstFalsy<T>;
}

// We use class-based helper to ensure arguments are lazy-evaluated
// and helper short-circuits like native JavaScript `&&` (logical AND).
export default class AndHelper<T extends unknown[]> extends Helper<
export default class AndHelper<const T extends MaybeTruthy[]> extends Helper<
AndSignature<T>
> {
public compute(params: T): T[number] {
public compute(params: T): FirstFalsy<T>;
public compute(params: T) {
for (let i = 0, len = params.length; i < len; i++) {
if (truthConvert(params[i]) === false) {
return params[i];
Expand Down
5 changes: 2 additions & 3 deletions packages/ember-truth-helpers/src/helpers/not.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import truthConvert from '../utils/truth-convert.ts';
import type { MaybeTruth } from '../utils/truth-convert.ts';
import truthConvert, { MaybeTruthy } from '../utils/truth-convert.ts';

export default function not(...params: MaybeTruth[]) {
export default function not(...params: MaybeTruthy[]) {
return params.every((param) => !truthConvert(param));
}
24 changes: 19 additions & 5 deletions packages/ember-truth-helpers/src/helpers/or.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
import truthConvert from '../utils/truth-convert.ts';
import truthConvert, {
MaybeTruthy,
TruthConvert,
} from '../utils/truth-convert.ts';
import Helper from '@ember/component/helper';

interface OrSignature<T extends unknown[]> {
type FirstTruthy<T> = T extends [infer Item]
? Item
: T extends [infer Head, ...infer Tail]
? TruthConvert<Head> extends true
? Head
: TruthConvert<Head> extends false
? FirstTruthy<Tail>
: Head | FirstTruthy<Tail>
: undefined;

interface OrSignature<T extends MaybeTruthy[]> {
Args: {
Positional: T;
};
Return: T[number];
Return: FirstTruthy<T>;
}

// We use class-based helper to ensure arguments are lazy-evaluated
// and helper short-circuits like native JavaScript `||` (logical OR).
export default class OrHelper<T extends unknown[]> extends Helper<
export default class OrHelper<T extends MaybeTruthy[]> extends Helper<
OrSignature<T>
> {
public compute(params: T): T[number] {
public compute(params: T): FirstTruthy<T>;
public compute(params: T) {
for (let i = 0, len = params.length; i < len; i++) {
if (truthConvert(params[i]) === true) {
return params[i];
Expand Down
82 changes: 73 additions & 9 deletions packages/ember-truth-helpers/src/utils/truth-convert.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,80 @@
import { isArray } from '@ember/array';
import type EmberArray from '@ember/array';

interface TruthyObject {
isTruthy: boolean;
}
type ConvertTruthyObject<T> = T extends { isTruthy: infer U } ? U : T;

// We check here in the order of the following function to maintain parity
// Note that this will not handle EmberArray correctly.
type _TruthConvert<T> = T extends { isTruthy: true }
? true
: T extends { isTruthy: false }
? false
: T extends { isTruthy: boolean }
? boolean
: T extends undefined | null
? false
: T extends boolean
? T
: T extends number
? T extends 0 | -0
? false
: true
: T extends bigint
? T extends 0n
? false
: true
: T extends string
? T extends ''
? false
: true
: T extends never[]
? false
: T extends ArrayLike<unknown>
? boolean
: T extends object
? true
: boolean;
export type TruthConvert<T> = _TruthConvert<ConvertTruthyObject<T>>;

export type MaybeTruth = TruthyObject | EmberArray<unknown> | unknown;
export type MaybeTruthy =
| { isTruthy: boolean }
| undefined
| null
| boolean
| number
| bigint
| string
| unknown[]
| object;

export default function truthConvert(result: MaybeTruth): boolean {
const truthy = result && (result as TruthyObject).isTruthy;
if (typeof truthy === 'boolean') {
return truthy;
// We also have to do individual overloads for each specific type so that we
// don't lose specificity.
export default function truthConvert<T extends true | { isTruthy: true }>(
result: T
): true;
export default function truthConvert<T extends { isTruthy: false }>(
result: T
): false;
export default function truthConvert<T extends undefined | null | false>(
result: T
): false;
export default function truthConvert<T extends number>(
result: T
): T extends 0 | -0 ? false : true;
export default function truthConvert<T extends bigint>(
result: T
): T extends 0n ? false : true;
export default function truthConvert<T extends string>(
result: T
): T extends '' ? false : true;
export default function truthConvert<T>(result: T): TruthConvert<T>;
export default function truthConvert(result: unknown): boolean {
if (
typeof result === 'object' &&
result &&
'isTruthy' in result &&
typeof result.isTruthy === 'boolean'
) {
return result.isTruthy;
}

if (isArray(result)) {
Expand Down
33 changes: 33 additions & 0 deletions packages/ember-truth-helpers/type-tests/helpers/and.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import AndHelper from '../../src/helpers/and';
import { MaybeTruthy } from '../../src/utils/truth-convert';

import { expectTypeOf } from 'expect-type';

function computeAnd<T extends MaybeTruthy[]>(...params: T) {
const and = new AndHelper<T>();
return and.compute(params);
}

expectTypeOf(computeAnd()).toEqualTypeOf<undefined>();

expectTypeOf(computeAnd(undefined)).toEqualTypeOf<undefined>();
expectTypeOf(computeAnd(null)).toEqualTypeOf<null>();
expectTypeOf(computeAnd(1, false, null)).toEqualTypeOf<false>();
const stringEnum: 'foo' | 'bar' = 'foo';
expectTypeOf(
computeAnd(undefined, null, stringEnum)
).toEqualTypeOf<undefined>();
expectTypeOf(computeAnd(stringEnum, stringEnum)).toEqualTypeOf(stringEnum);
expectTypeOf(computeAnd(1, true, [])).toEqualTypeOf<never[]>();
expectTypeOf(computeAnd({ isTruthy: true })).toEqualTypeOf<{
isTruthy: true;
}>();
expectTypeOf(computeAnd({ isTruthy: true }, 1)).toEqualTypeOf<1>();
expectTypeOf(computeAnd({ isTruthy: false }, 1)).toEqualTypeOf<{
isTruthy: false;
}>();

const foo: { isTruthy: true } = { isTruthy: true };
expectTypeOf(computeAnd(foo, 1)).toEqualTypeOf<1>();

expectTypeOf(computeAnd({}, [])).toEqualTypeOf<never[]>();
28 changes: 28 additions & 0 deletions packages/ember-truth-helpers/type-tests/helpers/or.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import OrHelper from '../../src/helpers/or';
import { MaybeTruthy } from '../../src/utils/truth-convert';

import { expectTypeOf } from 'expect-type';

function computeOr<T extends MaybeTruthy[]>(...params: T) {
const or = new OrHelper<T>();
return or.compute(params);
}

expectTypeOf(computeOr()).toEqualTypeOf<undefined>();

expectTypeOf(computeOr(undefined)).toEqualTypeOf<undefined>();
expectTypeOf(computeOr(null)).toEqualTypeOf<null>();
expectTypeOf(computeOr(1, false, null)).toEqualTypeOf<1>();
const stringEnum: 'foo' | 'bar' = 'foo';
expectTypeOf(computeOr(undefined, null, stringEnum)).toEqualTypeOf(stringEnum);
expectTypeOf(computeOr(1, true, [])).toEqualTypeOf<1>();
expectTypeOf(computeOr({ isTruthy: true })).toEqualTypeOf<{
isTruthy: true;
}>();
expectTypeOf(computeOr({ isTruthy: true }, 1)).toEqualTypeOf<{
isTruthy: true;
}>();
expectTypeOf(computeOr({ isTruthy: false }, 1)).toEqualTypeOf<1>();

const foo: { isTruthy: true } = { isTruthy: true };
expectTypeOf(computeOr(foo, 1)).toEqualTypeOf(foo);
7 changes: 7 additions & 0 deletions packages/ember-truth-helpers/type-tests/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"include": [
"./**/*",
"../unpublished-development-types/**/*"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import truthConvert from '../../src/utils/truth-convert';
import type { TruthConvert } from '../../src/utils/truth-convert';
import { expectTypeOf } from 'expect-type';

expectTypeOf<TruthConvert<null>>().toEqualTypeOf<false>();
expectTypeOf<TruthConvert<undefined>>().toEqualTypeOf<false>();

expectTypeOf<TruthConvert<true>>().toEqualTypeOf<true>();
expectTypeOf<TruthConvert<false>>().toEqualTypeOf<false>();

expectTypeOf<TruthConvert<0>>().toEqualTypeOf<false>();
expectTypeOf<TruthConvert<-0>>().toEqualTypeOf<false>();
expectTypeOf<TruthConvert<0n>>().toEqualTypeOf<false>();
expectTypeOf<TruthConvert<1>>().toEqualTypeOf<true>();

expectTypeOf<TruthConvert<''>>().toEqualTypeOf<false>();
expectTypeOf<TruthConvert<'A String'>>().toEqualTypeOf<true>();

expectTypeOf<TruthConvert<{ foo: 1; isTruthy: true }>>().toEqualTypeOf<true>();
expectTypeOf<
TruthConvert<{ foo: 1; isTruthy: false }>
>().toEqualTypeOf<false>();
// isTruthy isn't a boolean but we still have a real object so it's truthy
expectTypeOf<TruthConvert<{ foo: 1; isTruthy: 1 }>>().toEqualTypeOf<true>();

expectTypeOf<TruthConvert<never[]>>().toEqualTypeOf<false>();
expectTypeOf<TruthConvert<string[]>>().toEqualTypeOf<boolean>();

expectTypeOf<
TruthConvert<never[] & { isTruthy: true }>
>().toEqualTypeOf<true>();

expectTypeOf(truthConvert(null)).toEqualTypeOf<false>();
expectTypeOf(truthConvert(undefined)).toEqualTypeOf<false>();

expectTypeOf(truthConvert(true)).toEqualTypeOf<true>();
expectTypeOf(truthConvert(false)).toEqualTypeOf<false>();

expectTypeOf(truthConvert(0)).toEqualTypeOf<false>();
expectTypeOf(truthConvert(-0)).toEqualTypeOf<false>();
expectTypeOf(truthConvert(0n)).toEqualTypeOf<false>();
// We can't enumerate every number
expectTypeOf(truthConvert(1)).toEqualTypeOf<true>();

expectTypeOf(truthConvert('' as const)).toEqualTypeOf<false>();
// We can't enumerate every string
expectTypeOf(truthConvert('A String')).toEqualTypeOf<true>();

expectTypeOf(truthConvert({ foo: 1, isTruthy: true })).toEqualTypeOf<true>();
expectTypeOf(truthConvert({ foo: 1, isTruthy: false })).toEqualTypeOf<false>();
// isTruthy isn't a boolean but we still have a real object so it's truthy
expectTypeOf(truthConvert({ foo: 1, isTruthy: 1 })).toEqualTypeOf<true>();

const neverArray: never[] = [];
expectTypeOf(truthConvert(neverArray)).toEqualTypeOf<false>();
// We don't know that it's empty
const emptyStringArray: string[] = [];
expectTypeOf(truthConvert(emptyStringArray)).toEqualTypeOf<boolean>();

const neverArrayWithTruthy = [] as never[] & { isTruthy: true };
neverArrayWithTruthy.isTruthy = true;
expectTypeOf(truthConvert(neverArrayWithTruthy)).toEqualTypeOf<true>();
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
<div>
{{log @andArg}}
{{log @andArg1}}
{{log @andArg2}}
{{log @andArg3}}
{{log @andArg4}}
{{log @orArg}}
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import templateOnly from '@ember/component/template-only';
interface Signature {
Element: HTMLDivElement;
Args: {
andArg: object | boolean;
orArg: object | boolean;
andArg1: unknown[];
andArg2: false;
andArg3: true;
andArg4: { isTruthy: true };
orArg: object;
};
}

Expand Down
7 changes: 5 additions & 2 deletions packages/modern-test-app/app/templates/helpers.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
{{xor true false}}

<AndOrTypeChecking
@andArg={{or (hash) (array)}}
@orArg={{and (hash) (array)}}
@andArg1={{and (hash) (array)}}
@andArg2={{and true false}}
@andArg3={{and true true}}
@andArg4={{and 1 (hash isTruthy=true)}}
@orArg={{or (hash) (array)}}
/>
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit dac1476

Please sign in to comment.