---
url: /rfcs/type-functions.md
description: >-
  Type-level Luau, indexed access helpers, and metatable modeling for library
  authors.
---

# Type Functions RFCs

*Written: {{ $frontmatter.date }}*

## Learning objectives

* You define small user type functions.
* You read and build table, function, and generic types.
* You index tables and list their keys in types.
* You query subtyping inside type functions.
* You know type functions need the new solver.

## Prerequisites

* [Generics](../strict-typing/generics.md)
* [Solver and Migration](../strict-typing/solver-and-migration.md)
* [Metatables and OOP](../luau-basics/metatables-and-oop.md)
* [Generics and Packs](./generics-and-packs.md)
* [How to read RFC status](./implemented.md)

## User-defined type functions

They compute types from types. You use them for reusable type-level helpers.

### Original problem

The type system could not express relations between types. Helpers that made every field optional or picked a field type needed any casts or reshaped APIs. Libraries lost precision at exactly the shared boundaries readers trust most.

### Change and motivation

You write type functions in ordinary Luau with a type function header. Parameters arrive as type values. You inspect them with the type API and return one type. Calls use angle brackets at type positions. The [user type functions RFC](https://rfcs.luau.org/user-defined-type-functions.html) runs them in a sandbox during analysis with no runtime cost.

### Implemented semantics

You declare with type function name in parentheses and end. The body sees only the sandbox libraries plus the types library. You return exactly one type. Errors and asserts become analysis errors at the call. Type aliases and type functions share scoping rules, so they reference each other freely.

Unlike a normal function in two ways, it runs at analysis time and takes and returns types. Unlike a built-in operator, you define it yourself.

Type functions need the new solver. You enable it for the file or project before you use this chapter. The old solver does not reduce user type functions.

### Before and after

Before:

```luau
--!strict
type MaybeName = string?
local nick: MaybeName = nil
print(nick)
```

```txt
nil
```

Every optional needs hand-written syntax. Helpers cannot build the shape for you.

After:

```luau
--!strict
type function Optional(t)
  return types.optional(t)
end
type MaybeName = Optional<string>
local nick: MaybeName = nil
print(nick)
```

```txt
nil
```

The helper wraps any type in an optional through the verified library call.

### Realistic example

You offer a type helper that makes a receipt id optional-safe for order records.

```luau
--!strict
type function Optional(t)
  return types.optional(t)
end
type Order = { id: number, note: Optional<string> }
local first: Order = { id = 7, note = nil }
print(first.id)
```

```txt
7
```

You define the one-line helper. You use it inside a table type. You build a record with a nil note. The checker accepts it because the field is optional.

### Strict implications

Type function bodies are dynamically typed Luau in a sandbox. The surrounding annotations still check strictly. A type function that errors on bad input gives callers precise diagnostics, so you validate tags with is and raise clear errors.

### Edge cases

You return one type, never a pack. Extra return values error. The sandbox lacks require and game access, so you compute from the arguments alone. Long-running type functions can hit analysis limits, so you keep helpers small.

### Status

The [user-defined type functions RFC](https://rfcs.luau.org/user-defined-type-functions.html) shows **Status**: Implemented. You use it today under the new solver. You still confirm it in Studio per [how to read status](./implemented.md).

## Generics in type functions

Generic values let type functions build and inspect generic function types. You use them for higher-order helpers.

### Original problem

Type functions rejected any type containing generics. Real code is full of generic functions, so even a helper that ignored generics failed at serialization. Classes from the environment failed the same way.

### Change and motivation

The types library gained a generic constructor plus getters and setters on function types. You create named type and pack generics, attach them to built function types, and read them back. The [generics in type functions RFC](https://rfcs.luau.org/support-for-generic-function-types-in-user-defined-type-functions.html) keeps lexical scoping, so reused names resolve per scope.

### Implemented semantics

You call types dot generic with a name and an optional pack flag. You build a function type with newfunction. You set parameters, returns, and the generic list. Names define identity, so two generics with one name in one scope are equal. Ill-scoped uses error at serialization.

Unlike plain types, generics stand for unknown types per use. Unlike syntax generics, you construct them with calls.

### Before and after

Before:

```luau
--!strict
type function Optional(t)
  return types.optional(t)
end
type MaybeId = Optional<number>
local id: MaybeId = 3
print(id)
```

```txt
3
```

Plain helpers cover concrete types. Generic function types need the new constructor.

After:

```luau
--!strict
type function Identity()
  local T = types.generic("T")
  local f = types.newfunction()
  f:setparameters({ head = { T } })
  f:setreturns({ head = { T } })
  f:setgenerics({ T })
  return f
end
type IdentityFn = Identity<>
local run: IdentityFn = function<T>(value: T): T
  return value
end
print(run(12))
```

```txt
12
```

You build the generic identity shape step by step. The alias names it. An ordinary generic function fits it.

### Realistic example

You describe a mapper helper that lifts any element function into service code.

```luau
--!strict
type function Mapper()
  local T = types.generic("T")
  local f = types.newfunction()
  f:setparameters({ head = { T } })
  f:setreturns({ head = { T } })
  f:setgenerics({ T })
  return f
end
type MapperFn = Mapper<>
local double: MapperFn = function<T>(value: T): T
  return value
end
print(double("sword"))
```

```txt
sword
```

You construct the generic function type. You implement it once. You run it with a string. The generic flows through untouched.

### Strict implications

Constructed generics check like written ones at the use. A caller passing the wrong value still errors. You keep generic lists ordered with types before packs, because a type after a pack is invalid.

### Edge cases

Reusing one name in nested function types shadows per scope, matching source syntax. Duplicate names in one list error. Holding a pack generic as a standalone value is allowed in the runtime even though no syntax spells it, so you only return it inside proper positions.

### Status

The [generics in type functions RFC](https://rfcs.luau.org/support-for-generic-function-types-in-user-defined-type-functions.html) shows **Status**: Implemented. You use it today under the new solver. You still confirm it in Studio per [how to read status](./implemented.md).

## Thread and buffer in type functions

They work in type functions now. You use them when helpers touch async or binary data.

### Original problem

The original type function API omitted two primitive types. Helpers that met a thread or buffer value had no constant to return or compare. Developers worked around the gap with errors or any.

### Change and motivation

The library gained thread and buffer constants, and tags accept both names. No new methods were needed. The [thread and buffer RFC](https://rfcs.luau.org/support-for-thread-and-buffer-types-in-user-defined-type-functions.html) closes the gap with two properties.

### Implemented semantics

You read types dot thread and types dot buffer like the number and string constants. You test tags with is using the same names. Both behave as immutable primitive instances.

Unlike tables and functions, no properties or methods hang off these tags. Unlike the old API, the gap is gone.

### Before and after

Before:

```luau
--!strict
type function IsText(t)
  if t:is("string") then
    return types.singleton(true)
  end
  return types.singleton(false)
end
type TextCheck = IsText<string>
local yes: TextCheck = true
print(yes)
```

```txt
true
```

Helpers distinguished strings. Threads and buffers had no matching constants.

After:

```luau
--!strict
type function IsBuffer(t)
  if t:is("buffer") then
    return types.singleton(true)
  end
  return types.singleton(false)
end
type BufferCheck = IsBuffer<buffer>
local yes: BufferCheck = true
print(yes)
```

```txt
true
```

The same shape now recognizes buffers. The tag test and singleton answer are both verified API.

### Realistic example

You tag incoming channel values so packet code branches on binary payloads.

```luau
--!strict
type function IsBuffer(t)
  if t:is("buffer") then
    return types.singleton(true)
  end
  return types.singleton(false)
end
type PacketFlag = IsBuffer<buffer>
local binary: PacketFlag = true
print(binary)
```

```txt
true
```

You test the tag. You answer with a singleton boolean. The alias carries the verdict into annotations.

### Strict implications

Tag tests refine nothing by themselves outside type functions. Inside helpers they route logic cleanly. Callers still see precise singletons, so downstream narrowing works as usual.

### Edge cases

Thread and buffer have no properties method, so you never call properties on them. You return the constants when you need the primitive itself rather than a verdict about it.

### Status

The [thread and buffer RFC](https://rfcs.luau.org/support-for-thread-and-buffer-types-in-user-defined-type-functions.html) shows **Status**: Implemented. You use it today under the new solver. You still confirm it in Studio per [how to read status](./implemented.md).

## Index operator

It reads a property type from another type. You use it to stay in sync with shared records.

### Original problem

You reached a field type by declaring a throwaway variable and wrapping it in typeof. That trick added runtime code for a type-level question. Growing records forced you to revisit every copy.

### Change and motivation

The built-in index function takes a type and a key type and reduces to the field type. Unions distribute across members. Metatable index chains resolve when the direct field is missing. The [index RFC](https://rfcs.luau.org/index-type-operator.html) removes the throwaway variable.

### Implemented semantics

You write index in angle brackets with the target type and the key singleton. Missing keys error. Key unions produce unions of field types. A union target distributes per member. Metatable lookup runs only after the direct search fails, with a bounded chain.

Unlike typeof indexing, no value is needed. Unlike raw access, metamethods participate.

### Before and after

Before:

```luau
--!strict
type Pet = { name: string, level: number }
local sample = { name = "Pip", level = 3 }
type Level = typeof(sample.level)
local stage: Level = 4
print(stage)
```

```txt
4
```

The sample exists only to name the field type. Deleting it breaks the alias.

After:

```luau
--!strict
type Pet = { name: string, level: number }
type Level = index<Pet, "level">
local stage: Level = 4
print(stage)
```

```txt
4
```

The alias reads the field directly from the type. No value is involved. Record growth elsewhere never touches this line.

### Realistic example

You derive a reward type from the canonical quest record.

```luau
--!strict
type Quest = { title: string, reward: number, zone: string }
type Reward = index<Quest, "reward">
local function grant(amount: Reward): string
  return `Granted {amount} coins.`
end
print(grant(50))
```

```txt
Granted 50 coins.
```

You define the quest once. You derive the reward. The granter takes exactly that. Changing the record updates the parameter automatically.

### Strict implications

Derived types check exactly like written ones. A renamed field errors at the index use, which points you at the real change. You keep keys as singletons, since a general string cannot select one field.

### Edge cases

An unknown key errors rather than widening to unknown. Inexact tables can surprise authors who expect silent widening, so you treat a missing key as a bug first. Deep metatable chains give up after a bound, so extremely layered lookups can fail to reduce.

### Status

The [index RFC](https://rfcs.luau.org/index-type-operator.html) shows **Status**: Implemented. You use it today under the new solver. You still confirm it in Studio per [how to read status](./implemented.md).

## Keyof operators

It lists the keys of a table type. You use it for registries and selectors.

### Original problem

You hand-wrote a union of key names beside every registry table. Each new entry needed two edits in two places. Skipping the second edit silently widened the selector to string or broke safety.

### Change and motivation

Keyof computes the key union from the table type. Rawkeyof computes only the direct keys and skips metatable contributions. Union targets reduce to the shared keys. The [keyof RFC](https://rfcs.luau.org/keyof-type-operator.html) keeps one source of truth.

### Implemented semantics

You write keyof in brackets around the table type. The result is a union of key singletons. Index-style lookup includes metatable keys, while rawkeyof excludes them. Union inputs yield the greatest common key subset.

Unlike a hand union, it follows the table automatically. Between the two operators, metatable keys count for keyof only.

### Before and after

Before:

```luau
--!strict
local beasts = { cat = 1, dog = 2 }
type Beast = "cat" | "dog"
local function count(kind: Beast): number
  return beasts[kind]
end
print(count("dog"))
```

```txt
2
```

The union duplicates the keys. Adding a beast means editing both lines.

After:

```luau
--!strict
local beasts = { cat = 1, dog = 2 }
type Beast = keyof<typeof(beasts)>
local function count(kind: Beast): number
  return beasts[kind]
end
print(count("dog"))
```

```txt
2
```

The alias derives from the value type. New keys flow in with no second edit. Bad keys still error.

### Realistic example

You guard a zone registry so teleport code only names real zones.

```luau
--!strict
local zones = { forest = 1, arena = 2, harbor = 3 }
type Zone = keyof<typeof(zones)>
local function travel(to: Zone): string
  return `Traveling to {to}.`
end
print(travel("arena"))
```

```txt
Traveling to arena.
```

You list zones once. You derive the selector. Travel accepts only known names. A typo fails before playtest.

### Strict implications

Selectors stay precise without maintenance. Indexing with a keyof value is safe because every member exists. You still handle the value behind the key, which may be optional or narrow.

### Edge cases

Union inputs keep only shared keys, which can collapse to never for disjoint shapes. Metatable-heavy classes need a conscious choice between keyof and rawkeyof. Overly wide tables produce wide key unions, so you keep registries as precise literals.

### Status

The [keyof RFC](https://rfcs.luau.org/keyof-type-operator.html) shows **Status**: Implemented. You use it today under the new solver. You still confirm it in Studio per [how to read status](./implemented.md).

## Rawget operator

It reads a direct field type without metamethods. You use it for precise override checks.

### Original problem

Index-style reads followed metatables. Code that needed the direct answer had no type-level spelling. Runtime rawget and its type story disagreed.

### Change and motivation

The built-in rawget function mirrors the runtime operator. It reads only direct fields. Missing direct fields error. Classes have no direct fields in this sense, so they error. The [rawget RFC](https://rfcs.luau.org/rawget-type-operator.html) reuses the index machinery with metatables disabled.

### Implemented semantics

You write rawget in brackets with the table type and key. Direct fields reduce normally. Inherited fields error instead of resolving. Error wording matches the index operator.

Unlike index, metatables never participate. Unlike rawkeyof, it reads one value rather than listing keys.

### Before and after

Before:

```luau
--!strict
type Base = { title: string }
type Title = index<Base, "title">
local name: Title = "Herbs"
print(name)
```

```txt
Herbs
```

Plain reads suffice for direct tables. Metatable layers motivate the raw form.

After:

```luau
--!strict
type Base = { title: string }
type Title = rawget<Base, "title">
local name: Title = "Herbs"
print(name)
```

```txt
Herbs
```

The raw read names the direct field. Same result here, stronger promise under metatables.

### Realistic example

You verify that a skin override actually defines its own art id.

```luau
--!strict
type Skin = { art: string }
type Art = rawget<Skin, "art">
local function paint(id: Art): string
  return `Paint {id}.`
end
print(paint("gold"))
```

```txt
Paint gold.
```

You require the direct field. The painter takes exactly that. Inherited art never satisfies this alias.

### Strict implications

You choose rawget when override presence matters. Callers must supply the field directly. You choose index when inheritance should count. The checker enforces your choice at the alias.

### Edge cases

Any metatable-provided field errors under rawget by design. Class types error because they expose no direct fields. You do not wrap the call in optionals to soften it; you switch to index when softness is correct.

### Status

The [rawget RFC](https://rfcs.luau.org/rawget-type-operator.html) shows **Status**: Implemented. You use it today under the new solver. You still confirm it in Studio per [how to read status](./implemented.md).

## Metatable type functions

They attach and read metatables in types. You use them for OOP patterns without values.

### Original problem

You applied metatable information only through typeof around a runtime call. That forced a value into existence for a type-level fact. Common class patterns carried boilerplate at every definition.

### Change and motivation

Setmetatable takes a table type and a metatable type and returns the combined type. Getmetatable reads the metatable back, respects the locked metatable field, and returns nil when none is present. Unions and intersections map per member. The [metatable functions RFC](https://rfcs.luau.org/metatable-type-functions.html) mirrors the runtime pair.

### Implemented semantics

You write setmetatable in brackets with both table types. Non-table first arguments error, including classes. A non-table second argument errors. Getmetatable on an unsupported type errors. Locked metatables surface their guard value.

Unlike the runtime call, nothing mutates and a new type is produced. Unlike manual annotation, the combination stays machine-checked.

### Before and after

Before:

```luau
--!strict
local base = { title = "scout" }
type Rank = typeof(setmetatable({ title = "scout" }, { __index = base }))
local rank: Rank = setmetatable({ title = "lead" }, { __index = base })
print(rank.title)
```

```txt
lead
```

The alias needs a runtime expression inside typeof. Readers parse values to learn types.

After:

```luau
--!strict
local base = { title = "scout" }
type Rank = setmetatable<{ title: string }, { __index: typeof(base) }>
local rank: Rank = setmetatable({ title = "lead" }, { __index = base })
print(rank.title)
```

```txt
lead
```

The alias combines two types directly. No extra value exists. The construction below still matches it.

### Realistic example

You type a pet class table and read its backing table type back.

```luau
--!strict
local PetMethods = { speak = "woof" }
type Pet = setmetatable<{ name: string }, { __index: typeof(PetMethods) }>
type Backing = getmetatable<Pet>
local pup: Pet = setmetatable({ name = "Pip" }, { __index = PetMethods })
print(pup.name)
```

```txt
Pip
```

You attach the method table as a type. You recover the metatable as a type. The instance construction matches the alias. The Backing alias documents the round trip.

### Strict implications

Combined types check method access through the metatable. Direct field writes still follow table rules. You annotate constructors with the combined alias so callers see methods immediately.

### Edge cases

The set name suggests mutation, but no mutation happens. Applying a metatable over a union maps per member, which can widen faster than you expect. A locked metatable field changes what getmetatable returns, so you test that path explicitly.

### Status

The [metatable type functions RFC](https://rfcs.luau.org/metatable-type-functions.html) shows **Status**: Implemented. You use them today under the new solver. You still confirm them in Studio per [how to read status](./implemented.md).

## Type issubtypeof

It asks the subtyping question inside a helper. You use it for type predicates.

### Original problem

Helpers hand-rolled subtype checks with tag tests and component loops. String checks needed singleton unwrapping. Enum checks looped unions. Table checks ballooned into partial algorithms that risked timeouts.

### Change and motivation

Type values gained an issubtypeof method that runs the real subtyping algorithm. You call it with the candidate supertype and receive a boolean. The [issubtypeof RFC](https://rfcs.luau.org/method-type-issubtypeof.html) deletes the hand-rolled versions.

### Implemented semantics

You write target colon issubtypeof of candidate inside a type function. The call returns a boolean in the sandbox. You branch on it or wrap it in a singleton for the type level. No new syntax is involved.

Unlike tag equality, it answers the semantic question, not the syntactic one. Unlike manual loops, it terminates through the real algorithm.

### Before and after

Before:

```luau
--!strict
type function IsText(t)
  if t:is("string") then
    return types.singleton(true)
  end
  return types.singleton(false)
end
type TextCheck = IsText<string>
local yes: TextCheck = true
print(yes)
```

```txt
true
```

Tag tests answer one question. Relationships need more.

After:

```luau
--!strict
type function IsText(t)
  if t:issubtypeof(types.string) then
    return types.singleton(true)
  end
  return types.singleton(false)
end
type TextCheck = IsText<string>
local yes: TextCheck = true
print(yes)
```

```txt
true
```

The method asks the checker directly. Singletons like literal strings now pass as text. The answer stays a singleton boolean.

### Realistic example

You accept any coin-like value, including singletons, in a shop helper.

```luau
--!strict
type function IsText(t)
  if t:issubtypeof(types.string) then
    return types.singleton(true)
  end
  return types.singleton(false)
end
type NameCheck = IsText<"Maya">
local named: NameCheck = true
print(named)
```

```txt
true
```

A literal singleton is a subtype of string. Tag equality would miss it. The method catches it.

### Strict implications

Predicates built this way stay honest at the type level. Callers branch on singleton true or false with normal narrowing. You keep the boolean inside the sandbox; only the singleton crosses into types.

### Edge cases

The method answers subtyping, not assignability through any suppression, so you still design boundaries with unknown where input is untrusted. Overusing it in wide unions can cost analysis time, so you short circuit on tags first for the obvious cases.

### Status

The [issubtypeof RFC](https://rfcs.luau.org/method-type-issubtypeof.html) shows **Status**: Implemented. You use it today under the new solver. You still confirm it in Studio per [how to read status](./implemented.md).

## Types optional

It wraps any type in an optional. You use it inside helpers that model missing data.

### Original problem

Every helper that needed an optional spelled the union with a nil singleton by hand. Codebases copied a tiny local helper into each module. Readers tripped over the repetition.

### Change and motivation

The types library gained an optional constructor. Unions gain nil through it. Other types wrap the same way. The [optional RFC](https://rfcs.luau.org/types-library-optional.html) standardizes the one-liner.

### Implemented semantics

You call types dot optional with one type. Union inputs gain nil among their members. Non-union inputs become a two-member union with nil. Behavior matches the hand-written union exactly.

Unlike the manual spelling, it is shorter. Unlike any, it stays precise.

### Before and after

Before:

```luau
--!strict
type function Optional(t)
  return types.unionof(t, types.singleton(nil))
end
type MaybeZone = Optional<string>
local zone: MaybeZone = nil
print(zone)
```

```txt
nil
```

The manual union works. Every helper repeats it.

After:

```luau
--!strict
type function Optional(t)
  return types.optional(t)
end
type MaybeZone = Optional<string>
local zone: MaybeZone = nil
print(zone)
```

```txt
nil
```

The library call replaces two calls with one. The meaning is identical.

### Realistic example

You model an optional subtitle for quest banners.

```luau
--!strict
type function Optional(t)
  return types.optional(t)
end
type Banner = { title: string, sub: Optional<string> }
local plain: Banner = { title = "Herbs", sub = nil }
print(plain.title)
```

```txt
Herbs
```

The title is required. The subtitle is optional through the helper. Nil passes cleanly.

### Strict implications

Wrapped fields read as optionals everywhere downstream. You narrow them before use. The helper never hides nil from the checker, which is the point.

### Edge cases

Calling it on a union that already holds nil is harmless. Calling it twice nests no deeper than once in meaning. You still prefer the question-mark shorthand in plain annotations and reserve the call for type function bodies.

### Status

The [optional RFC](https://rfcs.luau.org/types-library-optional.html) shows **Status**: Implemented. You use it today under the new solver. You still confirm it in Studio per [how to read status](./implemented.md).

## Extern tag

It renames the foreign-type tag. You use it when helpers meet Engine instances.

### Original problem

Foreign types arrived from C and C++ embedders. Internals first called them class types, which collided with the planned classes feature and confused authors. The user-facing tag kept the old name after internals moved on.

### Change and motivation

The tag value becomes extern. Tag tests use the new name. Documentation drops the class wording for foreign data. The [extern tag RFC](https://rfcs.luau.org/udtf-is-extern.html) fixes the inconsistency before the new solver leaves beta.

### Implemented semantics

You test type colon is with extern for foreign values. The old class spelling no longer matches. Tags for tables, functions, and primitives are unchanged. Engine instances like Player surface under the new tag.

Unlike the classes proposal, extern describes foreign data, never user classes. Unlike a new capability, only the name changes.

### Before and after

Before:

```luau
--!strict
type function IsForeign(t)
  if t:is("extern") then
    return types.singleton(true)
  end
  return types.singleton(false)
end
type ForeignCheck = IsForeign<Player>
local yes: ForeignCheck = true
print(yes)
```

```txt
true
```

The new spelling recognizes a Player as foreign. The old class spelling belongs to beta history.

After:

```luau
--!strict
type function IsForeign(t)
  if t:is("extern") then
    return types.singleton(true)
  end
  return types.singleton(false)
end
type TableCheck = IsForeign<{ name: string }>
local no: TableCheck = false
print(no)
```

```txt
false
```

Plain tables are not foreign. The same test answers false.

### Realistic example

You route foreign handles and plain records through different helpers.

```luau
--!strict
type function IsForeign(t)
  if t:is("extern") then
    return types.singleton(true)
  end
  return types.singleton(false)
end
type HandleCheck = IsForeign<Player>
local handle: HandleCheck = true
print(handle)
```

```txt
true
```

A Player tests foreign. A record would not. Downstream code picks the right path from the singleton.

### Strict implications

You never confuse extern with user OOP. Roblox classes come from the Engine, so they test extern. Your metatable classes stay tables. The checker keeps those worlds apart.

### Edge cases

Beta-era code testing class needs an update to extern. The [classes proposal](https://rfcs.luau.org/syntax-classes.html) has no Implemented line, so class in that sense never arrives here. You search helpers for the old spelling after enabling the new solver.

### Status

The [extern tag RFC](https://rfcs.luau.org/udtf-is-extern.html) shows **Status**: Implemented. You use it today under the new solver. You still confirm it in Studio per [how to read status](./implemented.md).

## Common mistakes

Wrong: you return two types from a helper.

```luau
--!strict
type function Optional(t)
  return types.optional(t)
end
type MaybeGem = Optional<string>
local gem: MaybeGem = "ruby"
print(gem)
```

The single return is correct. The mistake is adding a second return beside it. Fix it by returning exactly one type.

Wrong: you test foreign values with the beta spelling.

```luau
--!strict
type function IsForeign(t)
  if t:is("extern") then
    return types.singleton(true)
  end
  return types.singleton(false)
end
type HandleCheck = IsForeign<Player>
local handle: HandleCheck = true
print(handle)
```

The extern spelling is correct. The mistake is the old class string. Fix it by searching helpers for the old name.

Wrong: you expect type functions on the old solver.

```luau
--!strict
type Pet = { name: string, level: number }
type Level = index<Pet, "level">
local stage: Level = 2
print(stage)
```

This needs the new solver. The mistake is enabling nothing and reading a confusing error. Fix it by switching the file or project to the new solver first.

Wrong: you use index where rawget is meant.

```luau
--!strict
type Base = { title: string }
type Title = rawget<Base, "title">
local name: Title = "Herbs"
print(name)
```

The raw read is correct for direct-only promises. The mistake is reaching for index when inheritance must not count. Fix it by choosing per question asked.

## Exercises

1. Write a one-line optional helper and use it.
2. Derive a field type with index.
3. Derive a key union with keyof.
4. Attach a metatable in a type and read it back.
5. Answer a subtype question with issubtypeof.
6. Test a Player for the extern tag.

::: details Solution 1
Run this pure snippet in the Command Bar.

```luau
--!strict
type function Optional(t)
  return types.optional(t)
end
type MaybePet = Optional<string>
local pet: MaybePet = nil
print(pet)
```

```txt
nil
```

You wrap string in an optional. Nil passes. The helper stays one line.
:::

::: details Solution 2
Run this pure snippet in the Command Bar.

```luau
--!strict
type Quest = { title: string, reward: number }
type Reward = index<Quest, "reward">
local pay: Reward = 25
print(pay)
```

```txt
25
```

You read the reward type from the record. The local takes exactly that. No sample value exists.
:::

::: details Solution 3
Run this pure snippet in the Command Bar.

```luau
--!strict
local shop = { sword = 150, bow = 120 }
type Item = keyof<typeof(shop)>
local pick: Item = "bow"
print(shop[pick])
```

```txt
120
```

You derive the key union from the shop. The pick must name a real item. Indexing stays safe.
:::

::: details Solution 4
Run this pure snippet in the Command Bar.

```luau
--!strict
local Methods = { level = 1 }
type Mob = setmetatable<{ name: string }, { __index: typeof(Methods) }>
local one: Mob = setmetatable({ name = "Slime" }, { __index = Methods })
print(one.name)
```

```txt
Slime
```

You combine the data type with the method table. The construction matches. The name prints.
:::

::: details Solution 5
Run this pure snippet in the Command Bar.

```luau
--!strict
type function IsText(t)
  if t:issubtypeof(types.string) then
    return types.singleton(true)
  end
  return types.singleton(false)
end
type Check = IsText<"scout">
local yes: Check = true
print(yes)
```

```txt
true
```

The singleton passes the subtype test. Tag equality alone would miss it. The alias carries true.
:::

::: details Solution 6
Run this pure snippet in the Command Bar.

```luau
--!strict
type function IsForeign(t)
  if t:is("extern") then
    return types.singleton(true)
  end
  return types.singleton(false)
end
type HandleCheck = IsForeign<Player>
local handle: HandleCheck = true
print(handle)
```

```txt
true
```

A Player tests extern. The helper answers with a singleton. The annotation holds true.
:::

## Summary

* User helpers take and return types. They run under the new solver.
* Generics, thread, and buffer all work inside helpers.
* Index, keyof, and rawget derive types from shapes.
* Metatable operators combine and recover types.
* Issubtypeof and optional shorten predicates and unions.
* Extern names foreign data.
* Next: [Stdlib and Config](./stdlib-and-config.md).
