#How to avoid type assertions in TypeScript factory pattern?

19 messages · Page 1 of 1 (latest)

viral marsh
#

I have a model factory that handles both basic and relational models using discriminated types. Currently, I'm using as unknown as type assertions which I'd like to avoid. Here's a simplified version:

interface BaseModelOptions {
  resourceId: string;
}

interface RelationModelOptions extends BaseModelOptions {
  _relations: Readonly<Record<string, ModelOptions>>;
}

type ModelOptions = BaseModelOptions | RelationModelOptions;

type RelationsType<T> = T extends RelationModelOptions
  ? { _relations: T['_relations'] }
  : {};

type Model<TModelOptions extends ModelOptions> = Readonly<
  {
    resourceId: TModelOptions['resourceId'];
  } & RelationsType<TModelOptions>
>;

const createModel = <TModelOptions extends ModelOptions>(
  options: TModelOptions,
): Model<TModelOptions> => {
  if ('_relations' in options) {
    const opt = options as RelationModelOptions; // Want to avoid this assertion
    return {
      resourceId: opt.resourceId,
      _relations: opt._relations,
    } as unknown as Model<TModelOptions>; // Want to avoid this assertion
  }
  return {
    resourceId: options.resourceId,
  } as unknown as Model<TModelOptions>; // Want to avoid this assertion
};

const bojan = createModel({
  resourceId: 'bojan',
  _relations: {
    someKey: createModel({
      resourceId: 'Woop',
    }),
  },
});

Is there a way to structure this code to maintain the same type safety but avoid the type assertions? The factory should infer the correct return type based on whether the input has _relations or not.

next jackal
#

are you able to change the way your options are defined? this seems like it wants to be a discriminated union

#

also, i know this is a simplified version, but as written it's just a glorified identity function. in your real code do you do different things in your two branches?

viral marsh
# next jackal also, i know this is a simplified version, but as written it's just a glorified ...

So Model is a collection of schemas for validating our endpoint params, so to createModel we'd pass property list: TSchema (typebox object) (Which is just a TObject containing database columns as properties ex. ```ts
TObject<{
name: TString;
website: TOptional<TUnion<[TString, TNull]>>;
}>


```ts
import type { TSchema } from 'elysia';

interface ListSchema extends TSchema {}
interface ListSchemaWithRelations extends ListSchema {}

interface BaseModelOptions {
  resourceId: string;
  // Schema of properties
  list: TSchema;
}

interface RelationModelOptions extends BaseModelOptions {
  _relations: Readonly<Record<string, ModelOptions>>;
}

type ModelOptions = BaseModelOptions | RelationModelOptions;

// Fully inferred readonly
type Model<TModelOptions extends ModelOptions> = Readonly<{
  resourceId: TModelOptions['resourceId'];

  //   Based on _relations existing or not
  list: ListSchema | ListSchemaWithRelations;
}>;

I hope this makes sense.

viral marsh
next jackal
viral marsh
next jackal
#

can you show me at least a subset of the properties that ListSchema/ListSchemaWithRelations have in reality?

viral marsh
next jackal
#

i should have been clearer; the more important part is the difference between ListSchema and ListSchemaWithRelations. is what you shared ListSchema? if so can you give me an example of how ListSchemaWithRelations differs from that type?

viral marsh
# next jackal can you show me at least a subset of the properties that `ListSchema`/`ListSchem...

Essentially this is what I want but am getting some errors currently

import type { TObject } from '@sinclair/typebox';
import * as t from '@sinclair/typebox';

interface BaseModelOptions {
  resourceId: string;

  list: TObject;
}

interface RelationModelOptions extends BaseModelOptions {
  _relations: Readonly<Record<string, Model<any>>>;
}

type ModelOptions = BaseModelOptions | RelationModelOptions;

// Fully inferred readonly
type Model<TModelOptions extends ModelOptions> = Readonly<{
  resourceId: TModelOptions['resourceId'];
  _relations: TModelOptions extends RelationModelOptions
    ? TModelOptions['_relations']
    : never;  

  //   Based on _relations existing or not
  list: TModelOptions extends RelationModelOptions
    ? RelationalListParams<TModelOptions['list'], TModelOptions['_relations']>
    : BasicListParams<TModelOptions['list']>;
}>;

const ModelListSchema = t.Object({
  query: t.Optional(t.String()),
  queryColumns: t.Optional(t.Array(t.String())),
  orderBy: t.String(),
  order: t.Union([t.Literal('asc'), t.Literal('desc')]),
  offset: t.Number(),
  limit: t.Number(),
});

type ListSchema = typeof ModelListSchema;

interface BasicListParams<T extends TObject> extends ListSchema {
  queryColumns?: (keyof T['properties'])[];
  orderBy: keyof T['properties'];
}

interface RelationalListParams<
  T extends TObject,
  TRel extends Record<string, Model<ModelOptions>>,
> extends ListSchema {
  queryColumns?: (keyof T &
    `${TRel['resourceId']}.${keyof TRel['list']['properties']}`)[];
  orderBy: keyof T['properties'] &
    `${TRel['resourceId']}.${keyof TRel['list']['properties']}`;
}

Does this make sense?

next jackal
#

i think so. do you need to tie everything together that tightly though? i understand the temptation to prove as much as you can at the type level, but at some point you hit diminishing returns

#

for example, would something like this be sufficient?

inland scaffoldBOT
#
mkantor#0

Preview:```ts
interface BaseModelOptions {
resourceId: string
}

interface RelationModelOptions
extends BaseModelOptions {
_relations: Readonly<Record<string, ModelOptions>>
}

type ModelOptions =
| BaseModelOptions
| RelationModelOptions

interface ListSchema {
query?: s
...```

next jackal
#

if you really want to prove all of the stuff you were trying to prove then you need to make ListSchema | RelationalListParams<T> be a discriminated union. otherwise all of that fancy generic stuff in RelationalListParams doesn't matter because the value can always just be treated as a ListSchema

#

same goes for ModelOptions

viral marsh
next jackal
#

i would start by reading up on discriminated unions as i mentioned before. that's how you represent "A xor B" at the type level in cases where A and B aren't naturally disjoint